diff --git a/.github/workflows/codemap-check.yml b/.github/workflows/codemap-check.yml new file mode 100644 index 0000000..9318c30 --- /dev/null +++ b/.github/workflows/codemap-check.yml @@ -0,0 +1,48 @@ +name: CODEMAP freshness + +# Guards the honor-system rule in docsource/CODEMAP.md: a PR that changes plugin +# source must also update the codemap, so agents/humans can trust it as the +# orientation map. The generated root README.md is handled separately by the +# Keyfactor bootstrap workflow (it auto-commits a regenerated README), so this +# check deliberately covers only the hand-maintained CODEMAP. +# +# Escape hatch: add the "skip-codemap" label to a PR when a source change +# genuinely does not affect the codemap (e.g. a comment-only or whitespace edit). + +on: + pull_request: + paths: + - 'markmonitor-caplugin/**' + +jobs: + codemap: + runs-on: ubuntu-latest + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-codemap') }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Require CODEMAP update when plugin source changes + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + changed="$(git diff --name-only "$BASE" "$HEAD")" + + # Production plugin source only: exclude build output and the test project. + src="$(printf '%s\n' "$changed" \ + | grep -E '^markmonitor-caplugin/.*\.cs$' \ + | grep -vE '/(bin|obj)/' || true)" + + codemap="$(printf '%s\n' "$changed" | grep -E '^docsource/CODEMAP\.md$' || true)" + + if [ -n "$src" ] && [ -z "$codemap" ]; then + echo "::error::Plugin source changed but docsource/CODEMAP.md was not updated. Update the codemap to match, or add the 'skip-codemap' label if this change genuinely does not affect it." + echo "Changed plugin source files:" + printf ' %s\n' $src + exit 1 + fi + + echo "CODEMAP freshness check passed." diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..019d5c2 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,39 @@ +name: Integration Tests + +# Manual-only: hits the real MarkMonitor sandbox API and creates/cancels real orders. Never runs on +# push or pull_request. The "markmonitor-integration" environment additionally gates every run +# behind a required reviewer approval, on top of this workflow_dispatch trigger. + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + environment: markmonitor-integration + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Add Keyfactor GitHub Packages NuGet source + run: | + dotnet nuget add source "https://nuget.pkg.github.com/Keyfactor/index.json" \ + --name keyfactor-github \ + --username keyfactor \ + --password "${{ secrets.V2BUILDTOKEN }}" \ + --store-password-in-clear-text + + - name: Run live integration tests + env: + MARKMONITOR_BASE_URL: ${{ secrets.MARKMONITOR_BASE_URL }} + MARKMONITOR_API_TOKEN: ${{ secrets.MARKMONITOR_API_TOKEN }} + MARKMONITOR_USERNAME: ${{ secrets.MARKMONITOR_USERNAME }} + MARKMONITOR_PASSWORD: ${{ secrets.MARKMONITOR_PASSWORD }} + run: dotnet test markmonitor-caplugin.IntegrationTests/markmonitor-caplugin.IntegrationTests.csproj --configuration Release --verbosity normal diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml new file mode 100644 index 0000000..bd05b07 --- /dev/null +++ b/.github/workflows/keyfactor-bootstrap-workflow.yml @@ -0,0 +1,19 @@ +name: Keyfactor Bootstrap Workflow + +on: + workflow_dispatch: + pull_request: + types: [opened, closed, synchronize, edited, reopened] + push: + create: + branches: + - 'release-*.*' + +jobs: + call-starter-workflow: + uses: keyfactor/actions/.github/workflows/starter.yml@v5 + secrets: + token: ${{ secrets.V2BUILDTOKEN}} + gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} + gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} + scan_token: ${{ secrets.SAST_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..be350ea --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,42 @@ +name: Unit Tests + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + + # markmonitor-caplugin.Tests references markmonitor-caplugin, which restores several + # Keyfactor.* packages from Keyfactor's GitHub Packages feed - not just nuget.org. + # + # Deliberately not locked down with a checked-in nuget.config + packageSourceMapping: the + # keyfactor-bootstrap-workflow's shared dotnet-build-and-release action adds its own NuGet + # source (named "github", credentialed with this same token) via the same mechanism, and a + # repo-wide nuget.config restricting Keyfactor.* to a source name/mapping this workflow alone + # controls would leave that other, already-green workflow's differently-named source + # unable to resolve any Keyfactor.* package. + - name: Add Keyfactor GitHub Packages NuGet source + run: | + dotnet nuget add source "https://nuget.pkg.github.com/Keyfactor/index.json" \ + --name keyfactor-github \ + --username keyfactor \ + --password "${{ secrets.V2BUILDTOKEN }}" \ + --store-password-in-clear-text + + - name: Run unit tests + run: dotnet test markmonitor-caplugin.Tests/markmonitor-caplugin.Tests.csproj --configuration Release --verbosity normal diff --git a/.gitignore b/.gitignore index 6b4a476..d78b105 100644 --- a/.gitignore +++ b/.gitignore @@ -364,4 +364,21 @@ FodyWeavers.xsd *.env *.env.local -*.idea/ \ No newline at end of file +*.idea/ + +# Local scratch/sandbox artifacts - contain sandbox credentials or org/contact data +# and are not meant to be committed. +.DS_Store +/order_from_*.json +TestConsole/Scripts/ +TestConsole/lib/list_real.json +TestConsole/lib/request.json +# Claude Code local settings (never commit) +.claude/ + +# Jekyll local preview build output/vendored gems (see `just docs-preview`) +docs/_site/ +docs/.jekyll-cache/ +docs/.bundle/ +docs/vendor/ +docs/Gemfile.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..3b529de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Features + +- initial release diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..cbb6bfd --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,459 @@ +# Developer Guide + +This document covers local development, testing, live API smoke-testing, and the internal design of +the MarkMonitor AnyCA Gateway REST plugin. For production deployment and CA connector configuration +for end users, see [README.md](README.md). + +## Prerequisites + +- .NET SDK 8.0 **and** 10.0 (the plugin dual-targets `net8.0` and `net10.0`). +- A MarkMonitor API key and service account for live smoke-testing (see + [Live smoke-testing](#live-smoke-testing-testconsole)). Not required to build or run the unit + tests. + +## Solution Layout + +The solution (`markmonitor-caplugin.sln`) contains four projects: + +| Project | Purpose | +|---|---| +| `markmonitor-caplugin/` | The plugin itself. Produces `MarkMonitorCAPlugin.dll` per TFM under `bin/Release//`. `manifest.json` is copied alongside the DLL on every build — it is how the AnyCA Gateway host discovers the plugin type (`Keyfactor.Extensions.CAPlugin.MarkMonitor.MarkMonitorCAPlugin`). | +| `markmonitor-caplugin.Tests/` | xUnit unit-test project (mocked HTTP, no live API). Targets `net8.0`. | +| `markmonitor-caplugin.IntegrationTests/` | xUnit live-API test project — real `MarkMonitorClient` calls against the actual MarkMonitor API (authenticate, list orgs, list certificate orders, RSA/ECC enroll). Targets `net8.0`. Each test skips (no-op pass) when the `MARKMONITOR_*` env vars aren't set, so it's always safe to run; when creds are present it creates and cleans up real orders. See [Live Integration Tests](#live-integration-tests-markmonitor-caplugin-integrationtests) below. | +| `TestConsole/` | Manual live integration/smoke-test console app that drives `MarkMonitorClient` directly against a live or sandbox MarkMonitor API. Destructive by nature (creates real orders). | + +Key source files inside `markmonitor-caplugin/`: + +- **`MarkMonitorCAConnector.cs`** — `IAnyCAPlugin` implementation; the Gateway host entry point. +- **`Client/MarkMonitorClient.cs`** — the MarkMonitor REST HTTP client (auth, pagination, CSR + handling, status mapping, dedup cache). +- **`MarkMonitorCAPluginConfig.cs`** — CA-connection and enrollment-parameter schema, UI + annotations/defaults, and the canonical field-name constants (`ConfigConstants` / + `EnrollmentConfigConstants`). +- **`Models/`** — request/response DTOs plus `Enums.cs` (`CertOrderTypes`, `OrderStatus`, + `DomainControlValidationMethods`, etc.). Enum-to-API-string mapping goes through the + `[Description]` attribute + `EnumExtensions.GetDescription()`. + +## Build + +```shell +dotnet build markmonitor-caplugin.sln -c Release +``` + +This produces `MarkMonitorCAPlugin.dll` for each TFM under +`markmonitor-caplugin/bin/Release/net8.0/` and `.../net10.0/`, each with `manifest.json` copied +alongside. + +To deploy manually, copy the contents of the target framework's output directory into the Gateway's +`Extensions` folder and restart the AnyCA Gateway REST service (see the [README.md](README.md) +Installation section for the exact path). + +## Unit Tests + +The `markmonitor-caplugin.Tests/` project is an xUnit suite that exercises the connector and client +against a fake `HttpMessageHandler` (see `TestHelpers/FakeHttpMessageHandler.cs`) and injected fakes +(`FakeCertificateDataReader`, `FakeAnyCAPluginConfigProvider`, `ManualTimeProvider`) — no live API or +credentials are required, so it is safe to run in CI. + +```shell +dotnet test markmonitor-caplugin.sln -c Release +``` + +Coverage is collected via `coverlet.collector`. The suite covers, among other areas: + +- Connector operations — enroll, revoke, renew-or-reissue, connection-info validation, client + caching. +- Client behavior — inventory/sync, pagination and query encoding, order-ID GUID validation, subject + (`CN`) cleaning, ECC named-curve validation, cancel, error propagation, and enroll logging/redaction. + +When you add or change behavior, add or update tests here — a fake handler that returns canned +MarkMonitor JSON is the established pattern for new client tests. + +## Live Integration Tests (`markmonitor-caplugin.IntegrationTests`) + +The `markmonitor-caplugin.IntegrationTests/` project is an xUnit suite that hits the **real** +MarkMonitor API, covering the same scenarios `TestConsole/Program.cs` walks through by hand: +authenticate, list organizations, list certificate orders, and enroll (once with an RSA CSR, once +with an ECC CSR, both generated via `TestConsole.Helpers.CsrGenerator`/`EmailAddressGenerator` — +this project references `TestConsole/TestConsole.csproj` purely to reuse those two helpers, not to +run `TestConsole` itself). + +Every test reads the same four `MARKMONITOR_*` environment variables `TestConsole` uses and returns +immediately (a silent pass, not a skip/failure) if any are unset: + +``` +MARKMONITOR_BASE_URL +MARKMONITOR_API_TOKEN +MARKMONITOR_USERNAME +MARKMONITOR_PASSWORD +``` + +This makes the project safe to run unconditionally — in CI or on a laptop with no `.env` sourced — +without ever failing for lack of credentials. It is **not** wired into `.github/workflows/unit-tests.yml` +(that workflow runs `markmonitor-caplugin.Tests/markmonitor-caplugin.Tests.csproj` directly, not the +whole solution). + +To run locally: + +```shell +set -a && source .env && set +a # or TestConsole/.env +dotnet test markmonitor-caplugin.IntegrationTests -c Release +``` + +`.github/workflows/integration-tests.yml` runs the same command in CI, but only on a manual +`workflow_dispatch` — never on push or pull_request. The job also targets the `markmonitor-integration` +GitHub environment, which holds the four `MARKMONITOR_*` secrets and requires reviewer approval before +a dispatched run can access them — a second gate on top of the manual trigger, since a run creates +real (sandbox) orders. + +Unlike `TestConsole`, the enrollment tests have **no `MARKMONITOR_SKIP_CLEANUP` escape hatch** — each +one always cancels (falling back to revoke) the order it creates before returning, win or lose, +since this is a repeatable automated suite rather than a manual inspection tool. A cleanup failure +throws (failing the test loudly) instead of just logging a warning, so a billable order that +couldn't be cleaned up is never left behind silently. + +## Live Smoke-Testing (`TestConsole`) + +`TestConsole/Program.cs` runs a scripted sequence against a live (or sandbox) MarkMonitor API: +authenticate, list organizations, list certificate orders, then generate RSA/ECC CSRs and submit real +enrollment orders. It is a **manual smoke-test harness, not a repeatable CI test suite** — it creates +real orders and is destructive/live by nature. + +It requires real credentials as environment variables: + +``` +MARKMONITOR_BASE_URL # e.g. https://api.markmonitor.com (or your sandbox URL) +MARKMONITOR_API_TOKEN # the X-API-KEY value +MARKMONITOR_USERNAME # service-account username +MARKMONITOR_PASSWORD # service-account password +``` + +`TestConsole/.env` holds these locally and is gitignored. By default the console cancels/revokes the +orders it creates so test runs don't accumulate charges; set `MARKMONITOR_SKIP_CLEANUP=true` to leave +them in place. + +Run it with: + +```shell +dotnet run --project TestConsole +``` + +> **Test-environment note:** in the MarkMonitor test setup, an enrolled order's common name must be +> `.mmcertdomain.com` or the request is rejected, and issuance requires manual email +> approval — enrollments will sit pending until approved, then appear in Command on the next +> incremental sync. + +## Adding a New MarkMonitor Product + +1. Add a member to `CertOrderTypes` in `Models/Enums.cs` with the matching `[Description("...")]` + (the MarkMonitor cert-type string). +2. Add the same product-ID name to `product_ids` in `integration-manifest.json`. +3. No separate list needs editing — `GetProductIds()` derives from the enum, and enrollment maps the + Command product ID to the MarkMonitor string via `GetDescription()`. + +## Keeping Metadata and Docs in Sync + +- `integration-manifest.json` (repo root) drives Keyfactor's integration catalog metadata + (`product_ids`, `ca_plugin_config`, `enrollment_config`) — keep it in sync with + `MarkMonitorCAPluginConfig.cs` and `CertOrderTypes` whenever either changes. +- `docsource/configuration.md`, `docsource/overview.md`, and `docsource/architecture.md` are the + source-of-truth, customer-facing documentation fragments. Root `README.md` is generated by the + Keyfactor doctool (`~/RiderProjects/doctooldotnet`) from `docsource/configuration.md` + + `integration-manifest.json` — `configuration.md` pulls `architecture.md` in via + `{% include 'architecture.md' %}`. Never hand-edit `README.md`; edit the relevant docsource + fragment and regenerate. This `DEVELOPMENT.md` file, like `LICENSE`, is hand-maintained at the repo + root and is **not** part of the doctool pipeline — edit it directly. Its own `## Architecture` + section below is a deeper, method-level tier of the same diagrams in `docsource/architecture.md` — + update both when a lifecycle flow changes. +- `docsource/CODEMAP.md` is the orientation map for this repo — update it whenever you change the + architecture, add/move key files, or change the build/test layout. + +## Architecture + +This section describes how the MarkMonitor AnyCA Gateway REST plugin integrates with Keyfactor +Command and the MarkMonitor SSL certificate API. It covers the primary certificate lifecycle +operations — synchronization, enrollment, and revocation — and how the plugin routes each through +the MarkMonitor REST API. + +### Component Overview + +See [README.md](README.md#component-overview) for the high-level component diagram. The two source +files that matter most: + +* **`MarkMonitorCAConnector.cs`** — the `IAnyCAPlugin` entry point the Gateway host calls + (`Initialize`, `Enroll`, `Revoke`, `Synchronize`, `GetSingleRecord`, `Ping`, + `ValidateCAConnectionInfo`, `ValidateProductInfo`, `GetProductIds`, and the annotation pair). It + holds the deserialized connection config and a single lazily-built `MarkMonitorClient`. +* **`Client/MarkMonitorClient.cs`** — the HTTP client for the MarkMonitor REST API. It owns + bearer-token authentication, list pagination, CSR PEM/DER handling (via BouncyCastle), the + enrollment dedup cache, and the MarkMonitor-order-status → Keyfactor-status mapping. + +### Request Authentication + +MarkMonitor uses two credentials together. The API key is sent as the `X-API-KEY` header on the +authentication request; the service-account username and password are POSTed to +`/auth/v1/auth/authenticate`, which returns a bearer token and its lifetime (`expiresIn`). Every +subsequent request carries `Authorization: Bearer `. + +The token is cached in the `MarkMonitorClient` instance with a small safety buffer (it is treated as +expired 30 seconds early so a request that starts just before expiry doesn't race the token dying +mid-flight). Re-authentication is lazy and guarded by double-checked locking on a semaphore: callers +that find a valid token proceed without locking; only a caller that observes an +expired/missing token takes the lock, and re-checks inside it, so concurrent operations cannot race +each other into two authentication calls or send requests under a half-written token. + +``` +Authorization: Bearer where token ← POST /auth/v1/auth/authenticate + headers: X-API-KEY: + body: { username, password } +``` + +### Certificate Identifiers + +MarkMonitor identifies each order by a **GUID order ID**. That order ID is what the plugin stores in +Keyfactor Command as the `CARequestID`, and it is the identifier used for every post-enrollment +operation (get single record, revoke). Because order IDs are interpolated directly into request +URLs, the client validates that any incoming order ID parses as a GUID before using it — a corrupted +or manipulated `CARequestID` fails fast with a clear error rather than producing an unexpected URL +path segment. + +The configured `OrgId` may be supplied either as a friendly organization **name** or as a **GUID**. +The client resolves a name to its GUID by listing organizations filtered by name; a value that +already parses as a GUID is used directly. + +--- + +### Gateway Startup + +When the AnyCA Gateway process loads the connector, `Initialize` deserializes the CA connection data +into the plugin's config object. The `MarkMonitorClient` itself is not built until the first +operation needs it — `CreateAndAuthenticateClientAsync()` builds (or adopts an injected) client once +and caches it for the lifetime of the plugin instance. Each client method authenticates or +re-authenticates itself lazily, so startup does not force an eager authentication call. + +```mermaid +sequenceDiagram + participant GW as AnyCA Gateway + participant Plugin as MarkMonitorCAPlugin + participant API as MarkMonitor API + + GW->>Plugin: Initialize(configProvider, certificateDataReader) + Plugin->>Plugin: Deserialize CAConnectionData into config + Note over Plugin: Client is NOT built yet (lazy) + GW->>Plugin: Ping() + Plugin->>Plugin: CreateAndAuthenticateClientAsync()
(builds & caches the client once) + Plugin->>API: POST /auth/v1/auth/authenticate
(X-API-KEY header + username/password) + API-->>Plugin: Bearer token (+ expiry) + Plugin->>API: GET /certs/v1/organization + API-->>Plugin: Organizations + Plugin-->>GW: Ping OK (auth works and at least one org exists) +``` + +--- + +### Synchronization + +Keyfactor Command periodically synchronizes its certificate inventory with MarkMonitor. The plugin +retrieves all orders for the account, page by page (looping until `MarkMonitorPage.TotalPages` is +reached), and feeds the issued certificates into Command's buffer. + +> **Note:** The current implementation performs a **full** listing on every sync — the `lastSync` +> timestamp and `fullSync` flag passed by the framework are not yet used to filter orders by date, +> and there is no configurable page size (the connector requests a fixed page size of 100). Orders +> that have no certificate yet are skipped for the current sync rather than aborting the page. + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as MarkMonitorCAPlugin + participant API as MarkMonitor API + + CMD->>Plugin: Synchronize(buffer, lastSync, fullSync, cancelToken) + Plugin->>Plugin: CreateAndAuthenticateClientAsync() + + loop Retrieve one page at a time (until TotalPages) + Plugin->>API: GET /certs/v1/order?page=N&size=100 + API-->>Plugin: Page of order records + + loop For each order on the page + alt Order has no cert yet (e.g. CREATED / DIGI_NEEDS_CSR) + Plugin->>Plugin: Skip for this sync + else Order has a certificate + Plugin->>Plugin: Map MarkMonitor status → Keyfactor status + Plugin->>Plugin: Assemble end-entity + intermediate + root chain + Plugin->>CMD: Add AnyCAPluginCertificate to buffer + end + end + end + + Plugin->>CMD: CompleteAdding() + Plugin-->>CMD: Synchronization complete +``` + +For each imported certificate the plugin builds the full chain by concatenating the end-entity, +intermediate, and root certificates returned by MarkMonitor, and records a revocation date when the +order's `RevokeStatus` is `REVOKED`. (MarkMonitor does not expose a revocation *reason* on its order +records, so a reason is not populated on the imported record.) + +--- + +### Certificate Enrollment + +When a requester submits a certificate request through Keyfactor Command, the plugin translates it +into a MarkMonitor order. It resolves the organization, contact, and (optional) group; validates and +normalizes the CSR; and places the order. Because DCV/approval is required before issuance, an +accepted order typically comes back pending. + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as MarkMonitorCAPlugin + participant API as MarkMonitor API + + CMD->>Plugin: Enroll(csr, subject, san, productInfo, format, enrollmentType) + Plugin->>Plugin: CreateAndAuthenticateClientAsync() + Plugin->>Plugin: Check dedup cache (org|product|subject|csr) + + alt Identical enrollment already in flight / just completed + Plugin-->>CMD: Await & return the original result (no duplicate order) + else New request + Plugin->>Plugin: Reserve dedup key BEFORE any real work + Plugin->>API: Resolve organization (name→GUID) & contact & group + Plugin->>Plugin: Parse CSR, reject ECC explicit-curve params,
derive RSA/ECC, convert to PEM + Plugin->>API: POST /certs/v1/order (product, org, contact, DCV method, CSR) + API-->>Plugin: Order created — GUID order ID + status + + alt enrollmentType == RenewOrReissue + Plugin->>API: Revoke prior cert (resolved from PriorCertSN) + end + + Plugin-->>CMD: EnrollmentResult (CARequestID = order ID, mapped status) + end +``` + +#### Enrollment inputs resolved from template parameters + +The plugin reads these product/template parameters (case-insensitive) when building the order — +falling back to defaults or a best-effort lookup when a value is missing or cannot be resolved: + +* **Organization** — resolved from the connector `OrgId` (name or GUID). Enrollment fails if it + cannot be resolved. +* **Contact** (`MarkmonitorContact`) — matched within the org's contacts by GUID, then email, then + `First Last` name. Falls back to the org's `ORGANIZATION_CONTACT` (or the first contact) if the + parameter is blank or unresolved (logged as a warning). +* **Group** (`MarkmonitorGroup`) — MarkMonitor groups are account-/tenant-wide, so this is matched by + GUID or exact (case-insensitive) name against `/auth/v1/group`. A blank or unresolved group is + simply omitted (best-effort; never fails the enrollment). +* **DCV method** (`DCVMethod`) — validated against `EMAIL`, `DNS_CNAME_TOKEN`, `HTTP_TOKEN`, + `DNS_TXT_TOKEN`; an invalid value logs a warning and falls back to `EMAIL`. +* **Additional emails** (`AdditionalEmails`), **comments** (`comments`), **locale** (`locale`), + **provider** (`provider`) — see the Template Enrollment Parameters table in + [README.md](README.md). + +#### Renewal / Reissue + +MarkMonitor exposes reissue and cancel endpoints (`PATCH /certs/v1/order/{id}/reissue`, +`PATCH /certs/v1/order/{id}/cancel`), but the enrollment path does **not** use them. A +`RenewOrReissue` enrollment always places a brand-new order and then revokes the prior certificate. +The prior certificate's serial number is supplied by the framework in +`productInfo.ProductParameters["PriorCertSN"]`; the plugin resolves it to a `CARequestID` via the +injected `ICertificateDataReader` and revokes it after the replacement has issued successfully. If +`PriorCertSN` is missing or cannot be resolved, the enrollment is treated as a plain new issuance and +no revoke is attempted — a failure to revoke the old certificate never fails delivery of the new one. + +```mermaid +flowchart TD + A([RenewOrReissue enrollment]) --> B[Place NEW MarkMonitor order] + B --> C{"PriorCertSN present
in product parameters?"} + C -- No --> D([Treat as new issuance — done]) + C -- Yes --> E["Resolve PriorCertSN → CARequestID
via ICertificateDataReader"] + E --> F{"Resolved and
OrgId configured?"} + F -- No --> G([Log warning — prior cert not revoked]) + F -- Yes --> H["Revoke prior order
(with org-ownership check)"] + H --> I([New cert delivered, prior revoked]) + D --> I + G --> I +``` + +--- + +### Revocation + +When a certificate is revoked in Keyfactor Command, the plugin first ensures an `OrgId` is +configured, then verifies the target order belongs to that organization before calling MarkMonitor's +revoke endpoint. + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as MarkMonitorCAPlugin + participant API as MarkMonitor API + + CMD->>Plugin: Revoke(orderId, hexSerialNumber, revocationReason) + Plugin->>Plugin: EnsureOrgNameConfigured()
(refuse if OrgId is blank) + Plugin->>Plugin: Validate orderId is a GUID + Plugin->>API: Resolve configured OrgId → GUID + Plugin->>API: GET /certs/v1/order/{orderId} + + alt Order org != configured org (compared as parsed GUIDs) + Plugin-->>CMD: Error — refusing to revoke (different organization) + else Order belongs to configured org + Note over Plugin: revocationReason has no MarkMonitor field —
logged if non-default, not sent + Plugin->>API: PATCH /certs/v1/order/{orderId}/revoke + API-->>Plugin: Revocation confirmed + Plugin-->>CMD: REVOKED + end +``` + +**Ownership check:** the configured org and the order's org are compared as **parsed GUIDs**, not raw +strings, so an admin-configured `OrgId` in a non-canonical format (braces, no dashes, etc.) still +matches MarkMonitor's canonical serialization of the same GUID. + +**Reason codes:** MarkMonitor's revoke schema has no reason field, so the Keyfactor reason code +cannot be forwarded. A non-default reason is logged rather than silently dropped. + +--- + +### Connector Validation + +When an administrator saves or edits the CA connector, `ValidateCAConnectionInfo` checks the supplied +fields before the connector can be saved in an enabled state. + +```mermaid +flowchart TD + A([Save connector configuration]) --> B{"ApiKey, Username,
Password all present?"} + B -- Missing --> E([Validation error shown to administrator]) + B -- Present --> C{"BaseUrl starts with https://
(or blank → default)?"} + C -- Not https --> E + C -- OK --> D{"OrgId present?"} + D -- Missing --> E + D -- Present --> F([Connector saved]) +``` + +`ValidateCAConnectionInfo` performs field-level validation only (it does not place a live API call); +`Ping` is the live connectivity check — it authenticates and lists organizations. `ValidateProductInfo` +is intentionally a no-op: contact/group values are resolved (and gracefully defaulted) at enroll +time rather than validated at template-save time, to avoid coupling template configuration to +MarkMonitor's availability. + +--- + +### Order Status Mapping + +See the [Order Status Mapping table](README.md#order-status-mapping) in README.md for the full +MarkMonitor-status → Keyfactor-status table. The mapping is implemented in +`MarkMonitorClient.MarkMonitorCertificateStatusToCAStatus`. + +`CREATED` is deliberately mapped to `EXTERNALVALIDATION`, not `INITIALIZED`. The AnyCA Gateway REST +framework treats `INITIALIZED` as a hard enrollment failure, which caused freshly-created orders +(that were in fact accepted by MarkMonitor and simply awaiting DCV/issuance) to be reported as +failures. `EXTERNALVALIDATION` is what the framework treats as "accepted, still pending". + +### API Endpoint Reference + +See the [API Endpoint Reference table](README.md#api-endpoint-reference) in README.md for the full +list of MarkMonitor endpoints the plugin calls. + +> The cancel and reissue endpoints exist in the client but are not currently invoked by the +> `IAnyCAPlugin` operations (enrollment reissue is implemented as new-order-plus-revoke; see +> [Renewal / Reissue](#renewal--reissue)). diff --git a/README.md b/README.md index 16f2a3e..61b3b5d 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,554 @@ -# cpr-cagateway-template +

+ Markmonitor AnyCA Gateway REST Plugin +

-## Template for new CA Gateway integrations +

+ +Integration Status: pilot +Release +Issues +GitHub Downloads (all assets, all releases) +

-### Use this repository to create new integrations for new CA Gateway integration types. +

+ + + Support + + · + + Requirements + + · + + Installation + + · + + License + + · + + Related Integrations + +

+The MarkMonitor AnyCA Gateway REST plugin extends the certificate lifecycle capabilities of the +MarkMonitor SSL certificate service to Keyfactor Command via the Keyfactor AnyCA Gateway REST. It +implements `IAnyCAPlugin` and is loaded as a DLL extension by the AnyCA Gateway REST host process — +it is not a standalone service. The plugin supports the following capabilities: + +* CA Synchronization: + * Downloads all certificate orders visible to the configured MarkMonitor organization and + imports the issued certificates (and their chains) into Keyfactor Command. + * Orders that have not yet produced a certificate are mapped to the appropriate pending/failed + status rather than imported as certificates. +* Certificate Enrollment for the SSL product types MarkMonitor exposes: + * Submits a new MarkMonitor certificate order per product type. + * A process-local dedup guard prevents Keyfactor Command retries from creating duplicate orders. +* Renewal / Reissue: + * MarkMonitor has no in-place "renew" enrollment endpoint through this plugin, so a + `RenewOrReissue` enrollment places a **new** order and then revokes the prior certificate once + the replacement has been created successfully. +* Certificate Revocation: + * Revokes a previously issued certificate, with a cross-organization ownership check that refuses + to revoke an order belonging to a different organization than the one the CA connector is + configured for. + +MarkMonitor's SSL API is backed by DigiCert (the only certificate `provider` its API currently +supports), so issued certificates chain up to DigiCert roots. + +## Compatibility + +The Markmonitor AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 25.5.0 and later. + +## Support +The Markmonitor AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. + +> To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab. + +## Requirements + +- A MarkMonitor **API Key** (contact MarkMonitor support to obtain one). +- A MarkMonitor **service account** (username and password) with permission to create certificate + orders. +- The **organization** name or ID (GUID) the certificates will be ordered under. +- Keyfactor Command >= v12.0.0. +- AnyCA Gateway REST >= v25.5.0. +- Network connectivity from the AnyCA Gateway host to the MarkMonitor API base URL, and trust of the + DigiCert issuing CA chain on both the gateway host and the Command server (see + [Gateway Registration](#gateway-registration)). + +## Installation + +1. Install the AnyCA Gateway REST per the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/InstallIntroduction.htm). + +2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [Markmonitor AnyCA Gateway REST plugin](https://github.com/Keyfactor/markmonitor-caplugin/releases/latest) from GitHub. + +3. Copy the unzipped directory (usually called `net8.0` or `net10.0`) to the Extensions directory: + + + ```shell + Depending on your AnyCA Gateway REST version, copy the unzipped directory to one of the following locations: + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net10.0\Extensions + ``` + + > The directory containing the Markmonitor AnyCA Gateway REST plugin DLLs (`net8.0` or `net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. + +4. Restart the AnyCA Gateway REST service. + +5. Navigate to the AnyCA Gateway REST portal and verify that the Gateway recognizes the Markmonitor plugin by hovering over the ⓘ symbol to the right of the Gateway on the top left of the portal. + +## Configuration + +1. Follow the [official AnyCA Gateway REST documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) to define a new Certificate Authority, and use the notes below to configure the **Gateway Registration** and **CA Connection** tabs: + + * **Gateway Registration** + + In order to enroll for certificates the Keyfactor Command server must trust the issuing CA chain. + MarkMonitor's default issuing CA (`provider`) is **DigiCert** — download and import the appropriate + certificate chain from to the AnyCA + Gateway host and Command server. + + Once the necessary files are copied to the appropriate locations and the AnyCA Gateway REST is up and + running, navigate to the AnyCA Gateway REST portal and configure the CA. + + ### Using file path for issuing CA certificate + ![gateway_registration_local_file.png](docsource/images/gateway_registration_local_file.png) + + ### Using Keyfactor Command certificate store for issuing CA certificate + > **⚠️ Warning:** The cert store must already exist in Keyfactor Command. + + ![gateway_registration_store.png](docsource/images/gateway_registration_store.png) + + * **CA Connection** + + Populate using the configuration fields collected in the [requirements](#requirements) section. + + * **ApiKey** - The API Key for the MarkMonitor API + * **Username** - Username for the MarkMonitor API service account + * **Password** - Password for the MarkMonitor API service account + * **BaseUrl** - The Base URL for the MarkMonitor API - Usually either https://api.markmonitor.com + * **OrgId** - The name of the MarkMonitor Organization to use for the API calls (ex: MarkMonitor). You can also use the Organization ID in GUID format. + * **Enabled** - Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available. + * **TimeoutSeconds** - The HTTP request timeout, in seconds, for calls to the MarkMonitor API (1-120). Default is 120. + * **PageSize** - The number of certificate orders requested per page during synchronization (1-500). Default is 100. + * **ForceCompleteSync** - When true, bypasses the skip-unchanged optimization and re-emits every order on every synchronization. Default is false. + * **PickupRetries** - How many times to poll a freshly-created order for issuance before returning it in its still-pending state (0-20). 0 disables polling. Default is 5. + * **PickupDelaySeconds** - The delay, in seconds, between issuance pickup polls (0-60). Default is 10. + +2. A certificate template must be created in Keyfactor Command for each MarkMonitor product type you +want to enroll. One template is required per product type (see [Product IDs](#product-ids)). Below is +an example of a template for a GeoTrust DV SSL certificate. For more on certificate product types, +contact your MarkMonitor administrator or support. + +![gateway_template.png](docsource/images/gateway_template.png) + +3. Follow the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) to add each defined Certificate Authority to Keyfactor Command and import the newly defined Certificate Templates. + +4. In Keyfactor Command (v12.3+), for each imported Certificate Template, follow the [official documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Configuring%20Template%20Options.htm) to define enrollment fields for each of the following parameters: + + * **AdditionalEmails** - List of 0 or more comma separated email addresses to send the certificate to via email after generation. + * **MarkmonitorGroup** - The name or GUID of a Markmonitor group to use for the certificate request. + * **MarkmonitorContact** - The name or GUID of a Markmonitor contact to use for the certificate request. Will use default Markmonitor organization contact if not specified. + * **DCVMethod** - The method to use for Domain Control Validation (DCV). Valid values are EMAIL, DNS_CNAME_TOKEN, HTTP_TOKEN, DNS_TXT_TOKEN. Default is EMAIL. + * **comments** - Comments to attach to the MarkMonitor order. Default is "Requested via Keyfactor Command". + * **locale** - Locale to use for the MarkMonitor order. Default is "en". + * **provider** - The certificate provider to use for the order. Default is "DIGICERT" (currently the only provider MarkMonitor's API supports). + * **RenewalWindowDays** - For a RenewOrReissue enrollment, how many days before its expiration a prior certificate must be within before it is revoked after being replaced. Outside this window, the prior certificate is left unrevoked and the request is treated like a plain new issuance. Default is 90. + +## MarkMonitor API Setup + +MarkMonitor requires **two** credentials that are used together (there is no OAuth mode): + +1. **API Key** — sent as the `X-API-KEY` header on the authentication request. Enter it in the + `ApiKey` connector field (masked in the Command UI). +2. **Service-account username and password** — POSTed to `/auth/v1/auth/authenticate`, which returns + a short-lived bearer token used on all subsequent calls. Enter them in the `Username` and + `Password` connector fields (the password is masked in the UI). + +Contact your MarkMonitor administrator to provision the API key and a service account with order +permissions, and to confirm the correct API base URL and organization name/ID for your environment. + +## Certificate Profiles + +The AnyCA Gateway REST portal requires a **certificate profile** for each MarkMonitor product you +intend to enroll against (see [Product IDs](#product-ids)) — this is separate from both the CA +connector configuration below and the Command certificate templates created afterward. Profiles can +be created by hand in the gateway portal, or with the helper script this repo ships: + +```shell +just register-gateway-profiles # create/update one profile per product, idempotent +just register-gateway-profiles 1 # dry run — preview only, no gateway calls +``` + +The script authenticates to the gateway's admin API (OAuth2 client-credentials, a bearer token, or a +pasted browser session cookie) and reads the product list from `integration-manifest.json`, so it +stays in sync with the product IDs above without hand-entering each one. See +`scripts/register-gateway-profiles.sh` and `scripts/lib/gateway-auth.sh` for the required environment +variables, or [Gateway Certificate Profile Quickstart](docsource/gateway-profile-quickstart.md) for a +walkthrough of each supported auth method. + +## CA Connection Configuration + +The following fields are presented in the AnyCA Gateway REST portal (and the Keyfactor Command +Management Portal) when creating or editing the MarkMonitor CA connector. All fields except `Enabled` +must be provided before the connector can be saved in an enabled state. + +![gateway_ca_configuration.png](docsource/images/gateway_ca_configuration.png) + +| Field | Required / Optional | Masked | Default | Description | +|---|---|---|---|---| +| `ApiKey` | Required | Yes | *(none)* | The MarkMonitor API key, sent as the `X-API-KEY` header when authenticating. | +| `Username` | Required | No | *(none)* | Username for the MarkMonitor API service account. | +| `Password` | Required | Yes | *(none)* | Password for the MarkMonitor API service account. | +| `BaseUrl` | Required | No | `https://api.markmonitor.com` | The MarkMonitor API base URL. Must start with `https://` — credentials and the bearer token are sent to it. | +| `OrgId` | Required | No | *(none)* | The MarkMonitor organization to use for API calls. Accepts either the organization **name** (e.g. `MarkMonitor`) or its **ID in GUID format**. Used to scope enrollment and to verify ownership on revoke. | +| `Enabled` | Optional | No | `true` | Enables or disables gateway functionality. Disable to allow the CA to be created before configuration information is available. | +| `TimeoutSeconds` | Optional | No | `120` | The HTTP request timeout, in seconds, for calls to the MarkMonitor API. Clamped to 1-120. | +| `PageSize` | Optional | No | `100` | The number of certificate orders requested per page during synchronization. Clamped to 1-500. | +| `ForceCompleteSync` | Optional | No | `false` | When `true`, bypasses the skip-unchanged synchronization optimization and re-emits every order on every sync. | +| `PickupRetries` | Optional | No | `5` | How many times `Enroll` polls a freshly-created order for issuance before returning it in its still-pending state. `0` disables polling. Clamped to 0-20. | +| `PickupDelaySeconds` | Optional | No | `10` | The delay, in seconds, between issuance pickup polls. Clamped to 0-60. | + +> **Note:** Credentials are stored in Keyfactor Command's encrypted gateway configuration. `ApiKey` +> and `Password` are masked in the UI and are never written to logs by the plugin. + +## Template Enrollment Parameters + +Custom enrollment parameters can be added to templates in Keyfactor Command after they have been +imported from the AnyCA Gateway. **All parameters are optional** and are read case-insensitively at +enrollment time. + +![template_enrollment_params.png](docsource/images/template_enrollment_params.png) + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `AdditionalEmails` | String | *(empty)* | Zero or more comma-separated email addresses that MarkMonitor will send the issued certificate to. Spaces are also treated as separators. | +| `MarkmonitorGroup` | String | *(none)* | The name or GUID of a MarkMonitor group to associate with the order. Matched by GUID or exact (case-insensitive) name against the account-wide group list. A blank or unresolved value is omitted (best-effort). | +| `MarkmonitorContact` | String | *(org default)* | The GUID, email, or `First Last` name of a MarkMonitor contact within the organization. Falls back to the organization's default contact if not specified or not resolvable. | +| `DCVMethod` | String | `EMAIL` | Domain Control Validation method. Valid values: `EMAIL`, `DNS_CNAME_TOKEN`, `HTTP_TOKEN`, `DNS_TXT_TOKEN`. An invalid value logs a warning and falls back to `EMAIL`. | +| `comments` | String | `Requested via Keyfactor Command` | Free-text comments attached to the MarkMonitor order. | +| `locale` | String | `en` | Locale for the MarkMonitor order. | +| `provider` | String | `DIGICERT` | The certificate provider for the order. `DIGICERT` is currently the only provider the MarkMonitor API supports. | +| `RenewalWindowDays` | Number | `90` | For a `RenewOrReissue` enrollment, how many days before its expiration the prior certificate must be within before it's revoked after the replacement issues. Outside that window, the prior certificate is left unrevoked and the request is treated like a plain new issuance. An invalid (non-numeric or non-positive) value falls back to the default. | + +> **Note on DCV:** The plugin passes the selected `DCVMethod` to MarkMonitor but does not itself +> automate DNS/HTTP token publication. For `EMAIL` (the default), MarkMonitor falls back to the +> domain/organization's registered DCV contacts; the plugin sends an empty `dcvEmails` list. + +## Product IDs + +`GetProductIds()` returns the names of the `CertOrderTypes` enum. The **Product ID** column is the +value Keyfactor Command sees and stores; the plugin maps it to the **MarkMonitor cert type** string +(the enum's `[Description]`) when placing an order. Adding a new MarkMonitor product means adding an +enum member with the matching `[Description]` — not editing a separate list. + +| Product ID (Command) | MarkMonitor cert type | Typical product family | +|---|---|---| +| `SslOvBasic` | `SSL_OV_BASIC` | OV SSL (basic) | +| `SslEvBasic` | `SSL_EV_BASIC` | EV SSL (basic) | +| `SslDvGeotrust` | `SSL_DV_GEOTRUST` | GeoTrust DV SSL | +| `SslDvThawte` | `SSL_DV_THAWTE` | Thawte DV SSL | +| `SslOvThawteWebserver` | `SSL_OV_THAWTE_WEBSERVER` | Thawte OV Web Server SSL | +| `SslEvThawteWebserver` | `SSL_EV_THAWTE_WEBSERVER` | Thawte EV Web Server SSL | +| `SslOvGeotrustTruebizid` | `SSL_OV_GEOTRUST_TRUEBIZID` | GeoTrust OV True BusinessID SSL | +| `SslEvGeotrustTruebizid` | `SSL_EV_GEOTRUST_TRUEBIZID` | GeoTrust EV True BusinessID SSL | +| `SslOvSecuresite` | `SSL_OV_SECURESITE` | DigiCert OV Secure Site SSL | +| `SslEvSecuresite` | `SSL_EV_SECURESITE` | DigiCert EV Secure Site SSL | +| `SslOvSecuresitePro` | `SSL_OV_SECURESITE_PRO` | DigiCert OV Secure Site Pro SSL | +| `SslEvSecuresitePro` | `SSL_EV_SECURESITE_PRO` | DigiCert EV Secure Site Pro SSL | + +> **Note:** The "typical product family" column is descriptive. Which product types your MarkMonitor +> account may actually order — and any per-product required fields — depend on your account's +> entitlements. Confirm availability with your MarkMonitor administrator. + +## Architecture + +This document describes how the MarkMonitor AnyCA Gateway REST plugin integrates with Keyfactor Command and the MarkMonitor SSL certificate API. It covers the primary certificate lifecycle operations — synchronization, enrollment, and revocation — and how the plugin routes each through the MarkMonitor REST API. + +## Component Overview + +``` +┌─────────────────────────────────────────────────────────┐ +│ Keyfactor Command │ +│ │ +│ Certificate Enrollment · Revocation · Sync Jobs │ +└────────────────────────────┬─────────────────────────────┘ + │ + AnyCA Gateway REST + (plugin host process) + │ +┌────────────────────────────▼─────────────────────────────┐ +│ The MarkMonitor plugin │ +│ │ +│ Translates Keyfactor operations into MarkMonitor API │ +│ calls and maps responses back to Command's data model. │ +└────────────────────────────┬─────────────────────────────┘ + │ HTTPS · Bearer token + X-API-KEY + │ +┌────────────────────────────▼─────────────────────────────┐ +│ MarkMonitor REST API (DigiCert) │ +│ │ +│ /auth/v1/auth/authenticate /certs/v1/order │ +│ /certs/v1/organization /auth/v1/group │ +└──────────────────────────────────────────────────────────┘ +``` + +## Request Authentication + +MarkMonitor uses two credentials together. The API key is sent as the `X-API-KEY` header on the authentication request; the service-account username and password are POSTed to `/auth/v1/auth/authenticate`, which returns a bearer token and its lifetime. Every subsequent request carries that token in an `Authorization: Bearer` header. + +``` +Authorization: Bearer where token ← POST /auth/v1/auth/authenticate + headers: X-API-KEY: + body: { username, password } +``` + +The token is cached for the lifetime of the plugin's API client and refreshed automatically shortly before it expires — a normal enrollment, sync, or revoke call never has to authenticate explicitly. There is no OAuth client-credentials mode. + +## Certificate Identifiers + +MarkMonitor identifies each order by a **GUID order ID**. That order ID is what the plugin stores in Keyfactor Command as the request identifier, and it is the identifier used for every post-enrollment operation (status check, revoke). Any order ID arriving from Command is validated as a GUID before use. + +The configured organization (`OrgId`) may be supplied either as a friendly **name** or as a **GUID** — the plugin resolves a name to its GUID by listing organizations and matching exactly (case-insensitive); a value that already parses as a GUID is used directly. -1. [Use this repository](#using-the-repository) -1. [Update the integration-manifest.json](#updating-the-integration-manifest.json) -1. [Add Keyfactor Bootstrap Workflow (keyfactor-bootstrap-workflow.yml)](#add-bootstrap) -1. [Create required branches](#create-required-branches) -1. [Replace template files/folders](#replace-template-files-and-folders) -1. [Create initial prerelease](#create-initial-prerelease) --- -#### Using the repository -1. Select the ```Use this template``` button at the top of this page -1. Update the repository name following [these guidelines](https://keyfactorinc.sharepoint.com/sites/IntegrationWiki/SitePages/GitHub-Processes.aspx#repository-naming-conventions) - 1. All repositories must be in lower-case - 1. General pattern: company-product-type - 1. e.g. hashicorp-vault-orchestator -1. Click the ```Create repository``` button +## Gateway Startup + +When the AnyCA Gateway loads the plugin, it deserializes the CA connection configuration first. The API client itself isn't built until the first operation needs it, and authentication is lazy on top of that — it doesn't happen until the Gateway calls `Ping()` to verify connectivity. + +```mermaid +sequenceDiagram + participant GW as AnyCA Gateway + participant Plugin as MarkMonitor plugin + participant API as MarkMonitor API + + GW->>Plugin: Initialize(configProvider, certificateDataReader) + Plugin->>Plugin: Deserialize CA connection config + Note over Plugin: Client not built yet (lazy) + GW->>Plugin: Ping() + Plugin->>Plugin: Build & cache the API client (first use only) + Plugin->>API: Authenticate (API key + username/password) + API-->>Plugin: Bearer token + Plugin->>API: List organizations + API-->>Plugin: Organizations + Plugin-->>GW: Ping OK (auth works and at least one org exists) +``` --- -#### Updating the integration-manifest.json +## Synchronization + +Keyfactor Command periodically synchronizes its certificate inventory with MarkMonitor. The plugin retrieves all certificate orders visible to the configured organization, page by page, and imports issued certificates — along with their full certificate chain — into Command. -*The following properties must be updated in the integration-manifest.json* +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as MarkMonitor plugin + participant API as MarkMonitor API -Clone the repository locally, use vsdev.io, or the GitHub online editor to update the file. + CMD->>Plugin: Start synchronization + Plugin->>API: Authenticate with MarkMonitor + + loop Retrieve one page of orders at a time + Plugin->>API: List certificate orders + API-->>Plugin: Page of order records + + loop For each order on the page + alt Order has no certificate yet + Plugin->>Plugin: Skip for this sync + else Order has a certificate + Plugin->>Plugin: Map the MarkMonitor status to a Keyfactor status + alt Unchanged since Command's last known status (and not forced) + Plugin->>Plugin: Skip re-emission + else New or changed + Plugin->>Plugin: Assemble the full certificate chain + Plugin->>CMD: Add certificate to Command's inventory + end + end + end + end + + Plugin-->>CMD: Synchronization complete (emitted / skipped-unchanged / errored counts) +``` + +> A record that fails to process is logged, counted, and skipped rather than aborting the sync - but +> the sync aborts outright if more than 25% of records fail once at least 50 have been observed. + +> The current implementation always performs a full listing of orders on each sync, rather than only +> retrieving certificates that changed since the last sync - `PageSize` optimizes the *mitigation*, not +> the listing itself: each order is compared against what Command already has for that request ID, and +> skipped (not re-emitted) when the status is unchanged, unless `ForceCompleteSync` is enabled or +> Command requests a full sync. Orders that have not yet produced a certificate are simply skipped for +> that sync rather than treated as an error. -* "name": "Friendly name for the integration" - * This will be used in the readme file generation and catalog entries -* "description": "Brief description of the integration." - * This will be used in the readme file generation - * If the repository description is empty this value will be used for the repository description upon creating a release branch -* "release_dir": "PATH\\\TO\\\BINARY\\\RELEASE\\\OUTPUT\\\FOLDER" - * Path separators can be "\\\\" or "/" - * Be sure to specify the release folder name. This can be found by running a Release build and noting the output folder - * Example: "AzureAppGatewayOrchestrator\\bin\\Release" -* "gateway_framework": "" string denoting the required command gateway framework version --- -#### Add Bootstrap -Add Keyfactor Bootstrap Workflow (keyfactor-bootstrap-workflow.yml). This can be copied directly from the workflow templates or through the Actions tab -* Directly: - 1. Create a file named ```.github\workflows\keyfactor-bootstrap-workflow.yml``` - 1. Copy the contents of [keyfactor/.github/workflow-templates/keyfactor-bootstrap-workflow.yml](https://raw.githubusercontent.com/Keyfactor/.github/main/workflow-templates/keyfactor-bootstrap-workflow.yml) into the file created in the previous step -* Actions tab: - 1. Navigate to the [Actions tab](./actions) in the new repository - 1. Click the ```New workflow``` button - 1. Find the ```Keyfactor Bootstrap Workflow``` and click the ```Configure``` button - 1. Click the ```Commit changes...``` button on this screen and the next to add the bootstrap workflow to the main branch - -A new build will run the tasks of a *Push* trigger on the main branch - -*Ensure there are no errors during the workflow run in the Actions tab.* +## Certificate Enrollment + +When a requester submits a certificate request through Keyfactor Command, the plugin translates it into a MarkMonitor order: it resolves the organization, contact, and (optional) group; validates and normalizes the CSR; and submits the order. MarkMonitor never issues synchronously from the create-order call - a newly submitted order always comes back pending (typically `CREATED`), since Domain Control Validation (and, in some environments, manual approval) is required first. + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as MarkMonitor plugin + participant API as MarkMonitor API + + CMD->>Plugin: Submit certificate request + Plugin->>API: Authenticate with MarkMonitor + Plugin->>Plugin: Check for a duplicate in-flight request + + alt An identical request was already submitted / just completed + Plugin-->>CMD: Return the original result (no duplicate order placed) + else New request + Plugin->>API: Resolve organization, contact, and group + Plugin->>Plugin: Validate and normalize the CSR + Plugin->>API: Submit the certificate order + API-->>Plugin: Order accepted — order ID and status + + opt Not yet issued and PickupRetries > 0 + loop Up to PickupRetries times, every PickupDelaySeconds + Plugin->>API: Poll the order + API-->>Plugin: Current status + end + end + + alt Renewal/Reissue request + Plugin->>API: Revoke the certificate being replaced + end + + Plugin-->>CMD: Enrollment result (order ID, current status) + end +``` + +For a product whose DCV/approval resolves quickly, this polling lets the issued certificate come back in the same enrollment call instead of always waiting for the next sync; `PickupRetries=0` disables it and restores the always-returns-pending behavior. + +> A concurrent duplicate request folded into this same in-flight reservation (the "identical request +> already submitted" branch above) receives the polled result too, not just the original pending +> status - the fold happens after polling completes, not before. + +### Renewal / Reissue + +MarkMonitor has no in-place "renew" enrollment endpoint through this plugin, so a Renewal/Reissue request always places a brand-new order. Once the replacement certificate has been created successfully, the plugin revokes the certificate it is replacing — but only if that certificate is within its `RenewalWindowDays` template parameter (default 90) of expiring. + +```mermaid +flowchart TD + A([Renewal / reissue enrollment]) --> B[Place a new MarkMonitor order] + B --> C{"Prior certificate
identified?"} + C -- No --> D([Treat as a new issuance - done]) + C -- Yes --> E["Resolve the prior order"] + E --> F{"Within the configured
renewal window?"} + F -- No --> G([Leave prior certificate unrevoked]) + F -- Yes --> H["Revoke the prior order"] + H --> I([New cert delivered, prior revoked]) + D --> I + G --> I +``` + +If the certificate being replaced can't be identified, or still has substantial life left (outside the renewal window), the request is simply treated as a new issuance and the prior certificate is left alone — a failure to revoke the old certificate never blocks delivery of the new one either. --- -#### Create required branches -1. Create a release branch from main: release-1.0 -1. Create a dev branch from the starting with the devops id in the format ab#\, e.g. ab#53535. - 1. For the cleanest pull request merge, create the dev branch from the release branch. - 1. Optionally, add a suffix to the branch name indicating initial release. e.g. ab#53535-initial-release +## Revocation + +When a certificate is revoked in Keyfactor Command, the plugin confirms that the target order belongs to the organization the CA connector is configured for before calling MarkMonitor's revoke operation. + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as MarkMonitor plugin + participant API as MarkMonitor API + + CMD->>Plugin: Revoke certificate + Plugin->>API: Authenticate with MarkMonitor + Plugin->>API: Look up the order's owning organization + + alt Order belongs to a different organization + Plugin-->>CMD: Error — refusing to revoke (different organization) + else Order belongs to the configured organization + Plugin->>API: Revoke the order + API-->>Plugin: Revocation confirmed + Plugin-->>CMD: Certificate marked revoked + end +``` + +> MarkMonitor's revoke operation has no field for a revocation reason code, so the reason supplied by +> Keyfactor Command cannot be forwarded to MarkMonitor. --- +## Connector Validation + +When an administrator saves or edits the CA connector, the plugin checks the supplied configuration before the connector can be saved in an enabled state. -#### Replace template files and folders -1. Replace the contents of readme_source.md -1. Create a CHANGELOG.md file in the root of the repository indicating ```1.0: Initial release``` -1. Replace the SampleOrchestratorExtension.sln solution file and SampleOrchestratorExtension folder with your new orchestrator dotnet solution -1. Push your updates to the dev branch (ab#xxxxx) +```mermaid +flowchart TD + A([Save connector configuration]) --> B{"API Key, Username,
Password all present?"} + B -- Missing --> E([Validation error shown to administrator]) + B -- Present --> C{"Base URL starts with https://
(or blank → default)?"} + C -- Not https --> E + C -- OK --> D{"Organization present?"} + D -- Missing --> E + D -- Present --> N{"Enabled?"} + N -- No --> F([Connector saved]) + N -- Yes --> G{"Authenticate with the
submitted credentials"} + G -- Fails --> E + G -- Succeeds --> H{"At least one organization
visible?"} + H -- No / fails --> E + H -- Yes --> F +``` + +After the field checks above pass - and only if the connector is being saved enabled - the plugin also places a live call to MarkMonitor: it authenticates with the submitted (not yet saved) credentials and confirms at least one organization is visible, using a transient client built from exactly what's about to be saved — never the connector's already-cached client, which could be validating stale credentials. Saving with `Enabled` set to `false` skips this live check entirely, preserving that field's own documented purpose: creating the connector before real credentials are available. --- +## Order Status Mapping + +MarkMonitor order statuses are mapped to Keyfactor statuses as follows: + +| MarkMonitor order status | Keyfactor status | +|---|---| +| `DIGI_PENDING`, `DIGI_PROCESSING`, `DIGI_REISSUE_PENDING`, `DIGI_WAITING_PICKUP`, `REISSUE_PENDING`, `DIGI_NEEDS_APPROVAL`, `REISSUE_REQUEST_PENDING` | `INPROCESS` | +| `CREATED` | `EXTERNALVALIDATION` (accepted, awaiting DCV/issuance) | +| `DIGI_ISSUED` | `GENERATED` (issued) | +| `DIGI_REVOKED` | `REVOKED` | +| `DIGI_FAILED`, `DIGI_REISSUE_FAILED` | `FAILED` | +| `DIGI_CANCELED`, `DIGI_REJECTED`, `DIGI_EXPIRED`, `DIGI_NEEDS_CSR` | `CANCELLED` | +| *(null/empty or unrecognized status)* | `FAILED` | + +> A freshly-submitted order (`CREATED`) is deliberately mapped to `EXTERNALVALIDATION` rather than a +> failure status — this means the order was accepted by MarkMonitor and is simply awaiting DCV or +> issuance. Once the order reaches `DIGI_ISSUED`, the next synchronization imports the certificate. + +## API Endpoint Reference + +The plugin calls the following MarkMonitor API endpoints. This is useful for firewall and network connectivity planning. -#### Create initial prerelease -1. Create a pull request from the dev branch to the release-1.0 branch +| Operation | MarkMonitor API endpoint | +|---|---| +| Authenticate / obtain bearer token | `POST /auth/v1/auth/authenticate` (with `X-API-KEY` header) | +| List certificate orders (sync) | `GET /certs/v1/order` (paginated via `page`/`size`) | +| Get a single order | `GET /certs/v1/order/{orderId}` | +| Place a new order (enroll) | `POST /certs/v1/order` | +| Revoke a certificate | `PATCH /certs/v1/order/{orderId}/revoke` | +| Cancel an order | `PATCH /certs/v1/order/{orderId}/cancel` | +| Reissue a certificate | `PATCH /certs/v1/order/{orderId}/reissue` | +| List organizations | `GET /certs/v1/organization` (paginated) | +| Get an organization | `GET /certs/v1/organization/{orgId}` | +| List groups | `GET /auth/v1/group` (paginated) | +> The cancel and reissue endpoints exist in the client but are not currently invoked by the +> `IAnyCAPlugin` operations — a Renewal/Reissue enrollment is implemented as a new order followed by +> revoking the prior certificate (see [Renewal / Reissue](#renewal--reissue)), not MarkMonitor's own +> reissue action. ----- +## License -When the repository is ready for SE Demo, change the following property: -* "status": "pilot" +Apache License 2.0, see [LICENSE](LICENSE). -When the integration has been approved by Support and Delivery teams, change the following property: -* "status": "production" +## Related Integrations -If the repository is ready to be published in the public catalog, the following properties must be updated: -* "update_catalog": true -* "link_github": true +See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway). diff --git a/TestConsole/Helpers/CSRGenerator.cs b/TestConsole/Helpers/CSRGenerator.cs index 152cb22..81f885d 100644 --- a/TestConsole/Helpers/CSRGenerator.cs +++ b/TestConsole/Helpers/CSRGenerator.cs @@ -1,3 +1,17 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using Org.BouncyCastle.Asn1.Sec; using Org.BouncyCastle.Asn1.X509; using Org.BouncyCastle.Crypto; @@ -117,7 +131,12 @@ private static AsymmetricCipherKeyPair GenerateEccKeyPair() { var keyPairGen = new ECKeyPairGenerator(); var ecSpec = SecNamedCurves.GetByName("secp256r1"); // P-256 curve - var ecDomainParams = new ECDomainParameters(ecSpec.Curve, ecSpec.G, ecSpec.N, ecSpec.H); + // ECNamedDomainParameters (not plain ECDomainParameters) is required so the CSR's + // SubjectPublicKeyInfo references the named curve by OID rather than spelling out explicit + // curve parameters (prime/coefficients/base point) - CA/Browser Forum baseline requirements + // disallow explicit EC parameters for publicly-trusted certs, and DigiCert silently rejects + // such a CSR (order fails almost instantly, with no reason surfaced via MarkMonitor's API). + var ecDomainParams = new ECNamedDomainParameters(SecObjectIdentifiers.SecP256r1, ecSpec); var keyGenParams = new ECKeyGenerationParameters(ecDomainParams, new SecureRandom(new CryptoApiRandomGenerator())); keyPairGen.Init(keyGenParams); diff --git a/TestConsole/Helpers/CertificateGenerator.cs b/TestConsole/Helpers/CertificateGenerator.cs index aa0dfd5..d952020 100644 --- a/TestConsole/Helpers/CertificateGenerator.cs +++ b/TestConsole/Helpers/CertificateGenerator.cs @@ -1,3 +1,17 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using Org.BouncyCastle.Asn1; using Org.BouncyCastle.Asn1.Sec; using Org.BouncyCastle.Asn1.X509; diff --git a/TestConsole/Helpers/DistinguishedNameGenerator.cs b/TestConsole/Helpers/DistinguishedNameGenerator.cs index ab38696..f6ff3f8 100644 --- a/TestConsole/Helpers/DistinguishedNameGenerator.cs +++ b/TestConsole/Helpers/DistinguishedNameGenerator.cs @@ -1,3 +1,17 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using System.Text.Json; namespace TestConsole.Helpers; diff --git a/TestConsole/Helpers/EmailAddressGenerator.cs b/TestConsole/Helpers/EmailAddressGenerator.cs index 5948ff9..918cf21 100644 --- a/TestConsole/Helpers/EmailAddressGenerator.cs +++ b/TestConsole/Helpers/EmailAddressGenerator.cs @@ -1,3 +1,17 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + namespace TestConsole.Helpers; public class EmailAddressGenerator : DistinguishedNameGenerator diff --git a/TestConsole/Helpers/TitleCaseConverter.cs b/TestConsole/Helpers/TitleCaseConverter.cs index 810e2e0..901ac22 100644 --- a/TestConsole/Helpers/TitleCaseConverter.cs +++ b/TestConsole/Helpers/TitleCaseConverter.cs @@ -1,3 +1,17 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using System.Globalization; namespace TestConsole.Helpers; diff --git a/TestConsole/Program.cs b/TestConsole/Program.cs index 14e4782..dbae771 100644 --- a/TestConsole/Program.cs +++ b/TestConsole/Program.cs @@ -1,5 +1,20 @@ -// See https://aka.ms/new-console-template for more information +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// See https://aka.ms/new-console-template for more information + +using Keyfactor.Extensions.CAPlugin.MarkMonitor; using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; using Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; using Keyfactor.PKI.PEM; @@ -55,79 +70,51 @@ private static async Task Main(string[] args) rsaCsrs.AddRange(eccCsrs); // rsaCsrs.AddRange(dsaCsrs); - var orders = new List(); - foreach (var (csr, privateKey, keyPair) in rsaCsrs) + // Every order this console creates is a real, billable MarkMonitor order. By default we + // clean each one up (cancel, falling back to revoke) right after creating it so repeated + // test runs don't rack up charges. Set MARKMONITOR_SKIP_CLEANUP=true to opt out and leave + // created orders in place (e.g. to inspect them manually in the MarkMonitor portal). + var skipCleanup = IsTruthy(Environment.GetEnvironmentVariable("MARKMONITOR_SKIP_CLEANUP")); + if (skipCleanup) + Console.WriteLine( + "MARKMONITOR_SKIP_CLEANUP is set - orders created by this run will NOT be cancelled/revoked automatically."); + + var orders = new List(); + foreach (var (csr, _, _) in rsaCsrs) { var csrPem = PemUtilities.DERToPEM(csr.GetEncoded(), PemUtilities.PemObjectType.CertRequest); var commonName = csr.GetCertificationRequestInfo().Subject.GetValueList()[0]; - var privateKeyPem = PemUtilities.DERToPEM(privateKey, PemUtilities.PemObjectType.PrivateKey); var randomNumberOfEmails = new Random().Next(1, 4); var additionalEmails = await EmailAddressGenerator.GenerateRandomEmailsAsync(randomNumberOfEmails); - var orgId = orgs[0].Id; - var orgGuid = Guid.Parse(orgId); - var orgContact = orgs[0].Contacts[0]; - var contactId = orgContact.Id; - var signatureAlgorithm = csr.SignatureAlgorithm.Algorithm.Id; - var requestAlgorithm = signatureAlgorithm switch - { - //check if algorithm is RSA or ECC - "1.2.840.113549.1.1.11" => AlgorithmTypes.Rsa.GetDescription(), - "1.2.840.10045.4.3.1" or "1.2.840.10045.4.3.2" or "1.2.840.10045.4.3.3" or "1.2.840.10045.4.3.4" - or "1.2.840.10045.2.1" => AlgorithmTypes.Ecc.GetDescription(), - // "2.16.840.1.101.3.4.3.1" or "2.16.840.1.101.3.4.3.2" or "2.16.840.1.101.3.4.3.3" - // or "2.16.840.1.101.3.4.3.4" => AlgorithmTypes.Dsa.GetDescription(), //DSA not supported - _ => throw new Exception($"Invalid signature algorithm {signatureAlgorithm}") - }; + Console.WriteLine("Creating certificate order via EnrollCertificateAsync..."); - var orderContacts = new List - { - new() - { - Id = contactId, - ContactTypes = orgContact.ContactTypes - } - }; + var client = new MarkMonitorClient(baseUrl, apiToken, username, password); + await client.AuthenticateAsync(); + Console.WriteLine("Authenticated."); - var dcvEmails = new List + var config = new MarkMonitorConfig { - new() - { - Email = "justin.mack@markmonitor.com", - DnsName = "mmcertdomain.com", - EmailDomain = "markmonitor.com" - } + ApiKey = apiToken, + ApiUsername = username, + ApiPassword = password, + BaseUrl = baseUrl, + OrgName = orgs[0].Name, + Enabled = true }; - var certOrder = new MarkMonitorCreateOrderRequest + var productParams = new Dictionary { - AdditionalEmails = additionalEmails, - SkipPrice = true, - OrganizationId = orgGuid, - // GroupId = null, - // Contacts = orderContacts, - Comments = "Requested via Keyfactor Command", - CertType = CertOrderTypes.SslDvGeotrust.GetDescription(), - Locale = "en", - Provider = "DIGICERT", - Cert = new MarkMonitorOrderRequestCert - { - CommonName = commonName, - Csr = csrPem, - // ServerPlatform = CertServerPlatforms.Default.GetDescription(), - DcvMethod = "EMAIL", - DcvEmails = new List(), - // Provider = "DIGICERT", - AlgorithmHash = requestAlgorithm - } + ["additionalEmails"] = string.Join(",", additionalEmails) }; - Console.WriteLine("Creating certificate order..."); - var client = new MarkMonitorClient(baseUrl, apiToken, username, password); - await client.AuthenticateAsync(); - Console.WriteLine("Authenticated."); - var order = await client.CreateCertificateOrder(certOrder); - Console.WriteLine($"Created certificate order: {order.Id}"); - orders.Add(order); + var enrollResult = await client.EnrollCertificateAsync(csrPem, $"CN={commonName}", + new Dictionary(), CertOrderTypes.SslDvGeotrust.ToString(), productParams, config); + + if (enrollResult == null) throw new Exception("EnrollCertificateAsync returned null"); + Console.WriteLine($"Created certificate order: {enrollResult.CARequestID} (status: {enrollResult.Status})"); + orders.Add(enrollResult.CARequestID); + + if (!skipCleanup) await CleanUpOrderAsync(client, enrollResult.CARequestID, config.OrgName); } Console.WriteLine("Tests completed successfully with orders: " + orders.Count); @@ -331,6 +318,38 @@ private static async Task Main(string[] args) // } // } // + /// + /// Best-effort cleanup for a real, billable MarkMonitor order this console just created. Tries + /// cancel first (orders created here are freshly submitted and never reach an issued state + /// before this runs), falling back to revoke in case the order somehow issued instantly. A + /// cleanup failure is logged, not thrown - it shouldn't fail the whole test run, but it should + /// be visible so the order can be cleaned up manually in the MarkMonitor portal. + /// + private static async Task CleanUpOrderAsync(MarkMonitorClient client, string orderId, string orgName) + { + try + { + await client.CancelCertificateAsync(orderId, orgName); + Console.WriteLine($"Cancelled order {orderId}."); + } + catch (Exception cancelEx) + { + try + { + await client.RevokeCertificateAsync(orderId, orgName); + Console.WriteLine($"Order {orderId} could not be cancelled ({cancelEx.Message}); revoked it instead."); + } + catch (Exception revokeEx) + { + Console.WriteLine( + $"WARNING: could not cancel or revoke order {orderId} - it may still incur charges and should be cleaned up manually. Cancel error: {cancelEx.Message}; Revoke error: {revokeEx.Message}"); + } + } + } + + private static bool IsTruthy(string? value) => + value is not null && (value.Equals("true", StringComparison.OrdinalIgnoreCase) || value == "1"); + private static async Task> TestListCertificateOrders(string baseUrl, string apiToken, string username, string password) { @@ -361,7 +380,7 @@ private static async Task> TestListOrgs(st foreach (var org in orgs) { Console.WriteLine($"Org: {org.Id} - {org.Provider} - {org.ProviderId}"); - foreach (var validation in org.Validations) + foreach (var validation in org.Validations ?? new List()) Console.WriteLine($"Validation: {validation.Name} - {validation.Type}"); } diff --git a/TestConsole/README.md b/TestConsole/README.md new file mode 100644 index 0000000..130cdda --- /dev/null +++ b/TestConsole/README.md @@ -0,0 +1,40 @@ +# TestConsole + +A manual integration-test console app that exercises `MarkMonitorClient`/`MarkMonitorCAPlugin` directly +against a live MarkMonitor API (sandbox or production). This is not a unit test suite - it's a +scripted smoke test you run by hand, and it makes real API calls that create real, billable orders. + +## Required environment variables + +``` +MARKMONITOR_BASE_URL +MARKMONITOR_API_TOKEN +MARKMONITOR_USERNAME +MARKMONITOR_PASSWORD +``` + +A local `.env` (gitignored) can hold these; source it before running, e.g.: + +```shell +set -a && source .env && set +a && dotnet run --project TestConsole +``` + +## Order cleanup + +Every enrollment this console performs creates a real MarkMonitor order, which is billable. **By +default, each order is cancelled (falling back to revoke if cancel fails) immediately after it's +created**, so repeated runs don't accumulate charges. + +To opt out and leave created orders in place instead - e.g. to inspect them manually in the +MarkMonitor portal, or to test something downstream of order creation - set: + +``` +MARKMONITOR_SKIP_CLEANUP=true +``` + +When this is set, the console prints a warning at startup and leaves every order it creates +untouched. **Anything created during such a run is your responsibility to clean up manually.** + +A cleanup failure (cancel and revoke both fail) does not fail the test run - it's logged as a +warning so you know to clean that specific order up by hand, since letting one cleanup failure +abort the whole run wouldn't help the orders created before it either. diff --git a/TestConsole/TestConsole.csproj b/TestConsole/TestConsole.csproj index 50b490a..eceea09 100644 --- a/TestConsole/TestConsole.csproj +++ b/TestConsole/TestConsole.csproj @@ -2,12 +2,12 @@ Exe - net6.0 + net8.0;net10.0 enable enable - + diff --git a/TestConsole/lib/MarkmonitorDigiCACert.pem b/TestConsole/lib/MarkmonitorDigiCACert.pem new file mode 100644 index 0000000..3f6b5f5 --- /dev/null +++ b/TestConsole/lib/MarkmonitorDigiCACert.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIEjTCCA3WgAwIBAgIQDQd4KhM/xvmlcpbhMf/ReTANBgkqhkiG9w0BAQsFADBh +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH +MjAeFw0xNzExMDIxMjIzMzdaFw0yNzExMDIxMjIzMzdaMGAxCzAJBgNVBAYTAlVT +MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j +b20xHzAdBgNVBAMTFkdlb1RydXN0IFRMUyBSU0EgQ0EgRzEwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQC+F+jsvikKy/65LWEx/TMkCDIuWegh1Ngwvm4Q +yISgP7oU5d79eoySG3vOhC3w/3jEMuipoH1fBtp7m0tTpsYbAhch4XA7rfuD6whU +gajeErLVxoiWMPkC/DnUvbgi74BJmdBiuGHQSd7LwsuXpTEGG9fYXcbTVN5SATYq +DfbexbYxTMwVJWoVb6lrBEgM3gBBqiiAiy800xu1Nq07JdCIQkBsNpFtZbIZhsDS +fzlGWP4wEmBQ3O67c+ZXkFr2DcrXBEtHam80Gp2SNhou2U5U7UesDL/xgLK6/0d7 +6TnEVMSUVJkZ8VeZr+IUIlvoLrtjLbqugb0T3OYXW+CQU0kBAgMBAAGjggFAMIIB +PDAdBgNVHQ4EFgQUlE/UXYvkpOKmgP792PkA76O+AlcwHwYDVR0jBBgwFoAUTiJU +IBiV5uNu5g/6+rkS7QYXjzkwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsG +AQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMDQGCCsGAQUFBwEB +BCgwJjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEIGA1Ud +HwQ7MDkwN6A1oDOGMWh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEds +b2JhbFJvb3RHMi5jcmwwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEW +HGh0dHBzOi8vd3d3LmRpZ2ljZXJ0LmNvbS9DUFMwDQYJKoZIhvcNAQELBQADggEB +AIIcBDqC6cWpyGUSXAjjAcYwsK4iiGF7KweG97i1RJz1kwZhRoo6orU1JtBYnjzB +c4+/sXmnHJk3mlPyL1xuIAt9sMeC7+vreRIF5wFBC0MCN5sbHwhNN1JzKbifNeP5 +ozpZdQFmkCo+neBiKR6HqIA+LMTMCMMuv2khGGuPHmtDze4GmEGZtYLyF8EQpa5Y +jPuV6k2Cr/N3XxFpT3hRpt/3usU/Zb9wfKPtWpoznZ4/44c1p9rzFcZYrWkj3A+7 +TNBJE0GmP2fhXhP1D/XVfIW/h0yCJGEiV9Glm/uGOa3DXHlmbAcxSyCRraG+ZBkA +7h4SeM6Y8l/7MBRpPCz6l8Y= +-----END CERTIFICATE----- +05EE9C2AC66F75D964AC5F1A3C7DE75D \ No newline at end of file diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 0000000..653b099 --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,7 @@ +source "https://rubygems.org" + +gem "jekyll", "~> 4.3" +gem "jekyll-remote-theme" +gem "jekyll-seo-tag" +gem "jekyll-include-cache" +gem "webrick" diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..46f3368 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,36 @@ +title: MarkMonitor AnyCA Gateway REST Plugin +description: >- + Customer documentation for the Keyfactor MarkMonitor AnyCA Gateway REST plugin — issue, + revoke, and synchronize certificates from the MarkMonitor SSL service through Keyfactor Command. +url: "https://keyfactor.github.io" +baseurl: "/markmonitor-caplugin" + +remote_theme: just-the-docs/just-the-docs +plugins: + - jekyll-remote-theme + +color_scheme: keyfactor-dark +logo: "/assets/images/logo.svg" + +mermaid: + version: "10.9.1" + +search_enabled: true +heading_anchors: true +back_to_top: true +back_to_top_text: "Back to top" +nav_sort: case_insensitive + +aux_links: + "Markmonitor-caplugin on GitHub": + - "https://github.com/Keyfactor/markmonitor-caplugin" +aux_links_new_tab: true + +footer_content: "Distributed under the Apache License 2.0. Supported by Keyfactor for Keyfactor customers." + +defaults: + - scope: + path: "" + type: "pages" + values: + layout: default diff --git a/docs/_includes/head_custom.html b/docs/_includes/head_custom.html new file mode 100644 index 0000000..ac4e345 --- /dev/null +++ b/docs/_includes/head_custom.html @@ -0,0 +1,44 @@ + + + + + + diff --git a/docs/_includes/header_custom.html b/docs/_includes/header_custom.html new file mode 100644 index 0000000..fd597e3 --- /dev/null +++ b/docs/_includes/header_custom.html @@ -0,0 +1,22 @@ + diff --git a/docs/_includes/mermaid_config.js b/docs/_includes/mermaid_config.js new file mode 100644 index 0000000..c18f66d --- /dev/null +++ b/docs/_includes/mermaid_config.js @@ -0,0 +1,10 @@ +/* The theme's default mermaid_config.js is just `{}`, which renders with Mermaid's light-oriented + "default" theme regardless of the page's color scheme - illegible against our dark background. + data-color-scheme is set by head_custom.html before this script runs (see components/mermaid.html). + NOTE: this repo's layout chain runs through a compress.html layout that strips newlines from the + final HTML, so double-slash line comments here would swallow the rest of the script - block + comments only, and never write the close-comment delimiter inside this comment's text. */ +(function () { + var scheme = document.documentElement.getAttribute("data-color-scheme"); + return { theme: scheme === "keyfactor" ? "default" : "dark" }; +})() diff --git a/docs/_sass/color_schemes/keyfactor-dark.scss b/docs/_sass/color_schemes/keyfactor-dark.scss new file mode 100644 index 0000000..cd48bc6 --- /dev/null +++ b/docs/_sass/color_schemes/keyfactor-dark.scss @@ -0,0 +1,20 @@ +// Dark-mode counterpart to ./keyfactor.scss - same purple brand accent (lightened for +// contrast against a dark background), theme dark neutrals otherwise. +$color-scheme: dark; +$body-background-color: $grey-dk-300; +$sidebar-color: $grey-dk-300; +$body-text-color: $grey-lt-300; +$body-heading-color: $grey-lt-000; +$link-color: lighten(#6844df, 18%); +$nav-child-link-color: $grey-dk-000; +$border-color: $grey-dk-200; +$btn-primary-color: lighten(#6844df, 12%); +$base-button-color: $grey-dk-250; +$code-background-color: #0d1117; // github-dark bg color +$code-linenumber-color: #e6edf3; // github-dark line number color +$feedback-color: darken($sidebar-color, 3%); +$table-background-color: $grey-dk-250; +$search-background-color: $grey-dk-250; +$search-result-preview-color: $grey-lt-300; + +@import "./vendor/accessible-pygments/github-dark"; diff --git a/docs/_sass/color_schemes/keyfactor.scss b/docs/_sass/color_schemes/keyfactor.scss new file mode 100644 index 0000000..8508386 --- /dev/null +++ b/docs/_sass/color_schemes/keyfactor.scss @@ -0,0 +1,14 @@ +// Keyfactor brand palette, matched to https://keyfactor.github.io/integrations-catalog/ +$body-background-color: #ffffff; +$sidebar-color: #ffffff; +$body-text-color: #313131; +$body-heading-color: #17102f; +$link-color: #6844df; +$nav-child-link-color: #6256a3; +$border-color: #e2e4ec; +$btn-primary-color: #6844df; +$base-button-color: #f0ecff; +$code-background-color: #f0ecff; +$feedback-color: #f0ecff; +$table-background-color: #ffffff; +$search-result-preview-color: #6b7084; diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss new file mode 100644 index 0000000..61a71cc --- /dev/null +++ b/docs/_sass/custom/custom.scss @@ -0,0 +1,40 @@ +// Dark/light toggle button (see _includes/header_custom.html and head_custom.html). Included by +// the theme's own css/custom.scss.liquid hook, so it's bundled into every generated color-scheme +// stylesheet automatically. +.color-scheme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + margin-left: 8px; + padding: 0; + border: 1px solid $border-color; + border-radius: 6px; + background: none; + color: $body-text-color; + cursor: pointer; +} + +.color-scheme-toggle:hover { + background-color: $base-button-color; +} + +.color-scheme-toggle-icon { + width: 16px; + height: 16px; +} + +// Default (and dark scheme): show the sun - clicking switches to light. +.color-scheme-toggle-icon--moon { + display: none; +} + +// Light scheme: show the moon - clicking switches to dark. +html[data-color-scheme="keyfactor"] .color-scheme-toggle-icon--sun { + display: none; +} + +html[data-color-scheme="keyfactor"] .color-scheme-toggle-icon--moon { + display: block; +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d2de113 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,229 @@ +--- +title: Architecture reference +layout: default +nav_order: 5 +--- + +# Architecture reference +{: .no_toc } + +Sequence and flow diagrams for the plugin's core certificate lifecycle operations - useful if you +want to understand exactly what happens, in what order, when Keyfactor Command asks this plugin to +start up, sync, enroll, renew, revoke, or validate a connection. + +1. TOC +{: toc} + +--- + +## Gateway startup + +When the AnyCA Gateway loads the plugin, it deserializes the CA connection configuration first. +Authentication is lazy - it doesn't happen until the Gateway calls `Ping()` to verify connectivity. + +```mermaid +sequenceDiagram + participant GW as AnyCA Gateway + participant Plugin as MarkMonitor Plugin + participant API as MarkMonitor API + + GW->>Plugin: Initialize(configProvider, certificateDataReader) + Plugin->>Plugin: Deserialize CA connection config + Note over Plugin: Not authenticated yet (lazy) + GW->>Plugin: Ping() + Plugin->>API: POST /auth/v1/auth/authenticate
(API key + username/password) + API-->>Plugin: Bearer token (+ expiry) + Plugin->>API: GET /certs/v1/organization + API-->>Plugin: Organizations + Plugin-->>GW: Ping OK (auth works and at least one org exists) +``` + +## Synchronization + +Keyfactor Command periodically syncs its certificate inventory with MarkMonitor. The plugin walks +every order, page by page, and feeds issued certificates into Command's buffer. + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as MarkMonitor Plugin + participant API as MarkMonitor API + + CMD->>Plugin: Synchronize(buffer, lastSync, fullSync, cancelToken) + Plugin->>API: Authenticate + + loop Retrieve one page at a time + Plugin->>API: GET /certs/v1/order?page=N&size=100 + API-->>Plugin: Page of order records + + loop For each order on the page + alt Order has no certificate yet + Plugin->>Plugin: Skip for this sync + else Order has a certificate + Plugin->>Plugin: Map MarkMonitor status to Keyfactor status + Plugin->>Plugin: Assemble end-entity + intermediate + root chain + Plugin->>CMD: Add certificate to buffer + end + end + end + + Plugin-->>CMD: Synchronization complete +``` + +## Certificate enrollment + +When someone requests a certificate through Keyfactor Command, the plugin resolves the +organization/contact/group, validates the CSR, and places a MarkMonitor order. Because domain +control validation is required before issuance, an accepted order typically comes back pending. + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as MarkMonitor Plugin + participant API as MarkMonitor API + + CMD->>Plugin: Enroll(csr, subject, san, productInfo, format, enrollmentType) + Plugin->>Plugin: Check for an identical enrollment already in flight + + alt Duplicate request + Plugin-->>CMD: Await & return the original result (no duplicate order) + else New request + Plugin->>API: Resolve organization, contact, and group + Plugin->>Plugin: Parse and validate the CSR + Plugin->>API: POST /certs/v1/order (product, org, contact, DCV method, CSR) + API-->>Plugin: Order created - order ID + status + + opt Not yet issued and PickupRetries > 0 + loop Up to PickupRetries times, every PickupDelaySeconds + Plugin->>API: Poll the order + API-->>Plugin: Current status + end + end + + alt Renewal / reissue + Plugin->>API: Revoke the prior certificate + end + + Plugin-->>CMD: Enrollment result (order ID, mapped status) + end +``` + +A product whose DCV/approval resolves quickly can come back issued from this same enrollment call +instead of always waiting for the next sync. Set `PickupRetries` to `0` to disable polling and +restore the always-returns-pending behavior. + +### Renewal / reissue + +A renewal or reissue always places a brand-new order first, then revokes the certificate it's +replacing - it never touches MarkMonitor's own reissue endpoint. If the prior certificate can't be +identified, the new certificate is still issued; only the revoke step is skipped. + +```mermaid +flowchart TD + A([Renewal / reissue enrollment]) --> B[Place a new MarkMonitor order] + B --> C{"Prior certificate
identified?"} + C -- No --> D([Treat as a new issuance - done]) + C -- Yes --> E["Resolve the prior order"] + E --> F{"Resolved and
organization configured?"} + F -- No --> G([Log a warning - prior cert not revoked]) + F -- Yes --> H["Revoke the prior order"] + H --> I([New cert delivered, prior revoked]) + D --> I + G --> I +``` + +## Revocation + +Before revoking a certificate, the plugin verifies the order actually belongs to the organization +configured on the connector - it refuses to revoke an order from a different organization. + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as MarkMonitor Plugin + participant API as MarkMonitor API + + CMD->>Plugin: Revoke(orderId, serialNumber, revocationReason) + Plugin->>API: Resolve the configured organization + Plugin->>API: GET /certs/v1/order/{orderId} + + alt Order belongs to a different organization + Plugin-->>CMD: Error - refusing to revoke + else Order belongs to the configured organization + Plugin->>API: PATCH /certs/v1/order/{orderId}/revoke + API-->>Plugin: Revocation confirmed + Plugin-->>CMD: Revoked + end +``` + +## Connector validation + +When an administrator saves or edits the CA connector, the plugin checks the supplied fields before +allowing it to be saved in an enabled state. If the connector is being saved *enabled*, it also makes +a live call to MarkMonitor to confirm the credentials actually work — using a client built from +exactly what's about to be saved, not the connector's already-cached client. Saving with `Enabled` +set to `false` skips that live check, so a connector can be created before real credentials are +available. + +```mermaid +flowchart TD + A([Save connector configuration]) --> B{"API key, username,
password all present?"} + B -- Missing --> E([Validation error shown to the administrator]) + B -- Present --> C{"Base URL uses https?"} + C -- No --> E + C -- Yes --> D{"Organization ID present?"} + D -- Missing --> E + D -- Present --> N{"Enabled?"} + N -- No --> F([Connector saved]) + N -- Yes --> G{"Authenticate with the
submitted credentials"} + G -- Fails --> E + G -- Succeeds --> H{"At least one organization
visible?"} + H -- No / fails --> E + H -- Yes --> F +``` + +--- + +## Order status mapping + +MarkMonitor order statuses are mapped to Keyfactor statuses as follows: + +| MarkMonitor order status | Keyfactor status | +|---|---| +| `DIGI_PENDING`, `DIGI_PROCESSING`, `DIGI_REISSUE_PENDING`, `DIGI_WAITING_PICKUP`, `REISSUE_PENDING`, `DIGI_NEEDS_APPROVAL`, `REISSUE_REQUEST_PENDING` | `INPROCESS` | +| `CREATED` | `EXTERNALVALIDATION` (accepted, awaiting DCV/issuance) | +| `DIGI_ISSUED` | `GENERATED` (issued) | +| `DIGI_REVOKED` | `REVOKED` | +| `DIGI_FAILED`, `DIGI_REISSUE_FAILED` | `FAILED` | +| `DIGI_CANCELED`, `DIGI_REJECTED`, `DIGI_EXPIRED`, `DIGI_NEEDS_CSR` | `CANCELLED` | +| *(null/empty or unrecognized status)* | `FAILED` | + +A freshly-submitted order (`CREATED`) is deliberately mapped to `EXTERNALVALIDATION` rather than a +failure status — the order was accepted by MarkMonitor and is simply awaiting DCV or issuance. Once +the order reaches `DIGI_ISSUED`, the next synchronization imports the certificate. + +## API endpoint reference + +| Operation | MarkMonitor API endpoint | +|---|---| +| Authenticate / obtain bearer token | `POST /auth/v1/auth/authenticate` (with `X-API-KEY` header) | +| List certificate orders (sync) | `GET /certs/v1/order` (paginated via `page`/`size`) | +| Get a single order | `GET /certs/v1/order/{orderId}` | +| Place a new order (enroll) | `POST /certs/v1/order` | +| Revoke a certificate | `PATCH /certs/v1/order/{orderId}/revoke` | +| Cancel an order | `PATCH /certs/v1/order/{orderId}/cancel` | +| Reissue a certificate | `PATCH /certs/v1/order/{orderId}/reissue` | +| List organizations | `GET /certs/v1/organization` (paginated) | +| Get an organization | `GET /certs/v1/organization/{orgId}` | +| List groups | `GET /auth/v1/group` (paginated) | + +The cancel and reissue endpoints exist in the client but aren't currently used — a renewal/reissue +places a new order and then revokes the prior certificate, rather than calling MarkMonitor's own +reissue action. + +--- + +Looking for implementation-level detail behind these diagrams — real class and method names, retry +and locking behavior, template-parameter resolution rules? See +[`DEVELOPMENT.md`](https://github.com/Keyfactor/markmonitor-caplugin/blob/main/DEVELOPMENT.md) in +the repository. diff --git a/docs/assets/css/just-the-docs-keyfactor-dark.scss b/docs/assets/css/just-the-docs-keyfactor-dark.scss new file mode 100644 index 0000000..06fcdfe --- /dev/null +++ b/docs/assets/css/just-the-docs-keyfactor-dark.scss @@ -0,0 +1,3 @@ +--- +--- +{% include css/just-the-docs.scss.liquid color_scheme="keyfactor-dark" %} diff --git a/docs/assets/css/just-the-docs-keyfactor.scss b/docs/assets/css/just-the-docs-keyfactor.scss new file mode 100644 index 0000000..38160b6 --- /dev/null +++ b/docs/assets/css/just-the-docs-keyfactor.scss @@ -0,0 +1,3 @@ +--- +--- +{% include css/just-the-docs.scss.liquid color_scheme="keyfactor" %} diff --git a/docs/assets/images/gateway_ca_configuration.png b/docs/assets/images/gateway_ca_configuration.png new file mode 100644 index 0000000..1c705d9 Binary files /dev/null and b/docs/assets/images/gateway_ca_configuration.png differ diff --git a/docs/assets/images/gateway_registration_local_file.png b/docs/assets/images/gateway_registration_local_file.png new file mode 100644 index 0000000..8a22761 Binary files /dev/null and b/docs/assets/images/gateway_registration_local_file.png differ diff --git a/docs/assets/images/gateway_registration_store.png b/docs/assets/images/gateway_registration_store.png new file mode 100644 index 0000000..9971515 Binary files /dev/null and b/docs/assets/images/gateway_registration_store.png differ diff --git a/docs/assets/images/gateway_template.png b/docs/assets/images/gateway_template.png new file mode 100644 index 0000000..028577e Binary files /dev/null and b/docs/assets/images/gateway_template.png differ diff --git a/docs/assets/images/logo.svg b/docs/assets/images/logo.svg new file mode 100644 index 0000000..825e719 --- /dev/null +++ b/docs/assets/images/logo.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/template_enrollment_params.png b/docs/assets/images/template_enrollment_params.png new file mode 100644 index 0000000..ab97218 Binary files /dev/null and b/docs/assets/images/template_enrollment_params.png differ diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..792bc95 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,15 @@ +--- +title: Changelog +layout: default +nav_order: 6 +--- + +# Changelog + +The changelog lives in one place — the repository's +[`CHANGELOG.md`](https://github.com/Keyfactor/markmonitor-caplugin/blob/main/CHANGELOG.md) — so it +never drifts out of sync with a copy here. See it for the full history of fixes, security updates, +and breaking changes by release. + +For the compiled binaries themselves, see the +[Releases page](https://github.com/Keyfactor/markmonitor-caplugin/releases). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..f5de100 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,106 @@ +--- +title: Configuration reference +layout: default +nav_order: 4 +--- + +# Configuration reference +{: .no_toc } + +Every connector field, enrollment parameter, and product ID this plugin exposes. +{: .fs-6 .fw-300 } + +1. TOC +{: toc} + +--- + +## CA Connection fields + +These fields appear on the **CA Connection** tab when you register MarkMonitor as a certificate +authority, in both the AnyCA Gateway REST portal and the Keyfactor Command Management Portal. Every +field except `Enabled` must be filled in before the connector can be saved and enabled. + +MarkMonitor CA Connection configuration screen + +| Field | Required | Masked | Default | What it's for | +|---|---|---|---|---| +| `ApiKey` | Yes | Yes | — | Your MarkMonitor API key. | +| `Username` | Yes | No | — | Username for your MarkMonitor service account. | +| `Password` | Yes | Yes | — | Password for your MarkMonitor service account. | +| `BaseUrl` | Yes | No | `https://api.markmonitor.com` | The MarkMonitor API address. Must start with `https://`. | +| `OrgId` | Yes | No | — | Your MarkMonitor organization — either its name (e.g. `MarkMonitor`) or its ID in GUID format. Used both to scope enrollment and to confirm ownership before a revoke. | +| `Enabled` | No | No | `true` | Turns the connector on or off. Useful for saving a connector before all configuration details are ready. | +| `TimeoutSeconds` | No | No | `120` | How long, in seconds, to wait on a single MarkMonitor API call before giving up. Clamped to 1-120. | +| `PageSize` | No | No | `100` | How many certificate orders to request per page during synchronization (1-500). | +| `ForceCompleteSync` | No | No | `false` | When `true`, re-imports every order on every sync instead of skipping ones that haven't changed. | +| `PickupRetries` | No | No | `5` | How many times enrollment polls a freshly-created order for issuance before giving up and returning it pending. `0` turns this off. Clamped to 0-20. | +| `PickupDelaySeconds` | No | No | `10` | How long to wait between issuance pickup polls. Clamped to 0-60. | + +Your API key and password are encrypted in Command's gateway configuration, masked in the UI, and +never written to logs. + +## Gateway Registration + +Before you can enroll, Command and the Gateway both need to trust MarkMonitor's issuing CA chain +(DigiCert). See [Installation](installation#2-trust-the-digicert-issuing-ca-chain) for the download +link, then register it here using either method: + +**A local file path:** + +Gateway Registration using a local file path + +**A Keyfactor Command certificate store** (the store must already exist in Command): + +Gateway Registration using a Command certificate store + +## Certificate templates + +Create one Command certificate template per MarkMonitor product you plan to enroll for (see +[Product IDs](#product-ids) below). Here's an example set up for a GeoTrust DV SSL certificate: + +Example certificate template for a GeoTrust DV SSL product + +## Template enrollment parameters + +Once a template is imported into Command, you can add these optional parameters to it. All of them +are read case-insensitively at enrollment time, so casing doesn't matter. + +Template enrollment parameters + +| Parameter | Type | Default | What it does | +|---|---|---|---| +| `AdditionalEmails` | String | *(none)* | One or more email addresses (comma- or space-separated) that MarkMonitor should send the issued certificate to. | +| `MarkmonitorGroup` | String | *(none)* | A MarkMonitor group to associate the order with — by name or GUID. Left blank or unresolved, it's simply omitted. | +| `MarkmonitorContact` | String | *(org default)* | The MarkMonitor contact for this order — by GUID, email, or full name. Falls back to your organization's default contact. | +| `DCVMethod` | String | `EMAIL` | How MarkMonitor should validate domain control: `EMAIL`, `DNS_CNAME_TOKEN`, `HTTP_TOKEN`, or `DNS_TXT_TOKEN`. An unrecognized value falls back to `EMAIL`. | +| `comments` | String | `Requested via Keyfactor Command` | Free-text note attached to the order. | +| `locale` | String | `en` | Locale for the order. | +| `provider` | String | `DIGICERT` | The certificate provider. `DIGICERT` is currently the only one MarkMonitor supports. | +| `RenewalWindowDays` | Number | `90` | For a Renewal/Reissue request, how many days before its expiration the certificate being replaced must be within before it's revoked. Outside that window, it's left alone and the request is treated like a plain new certificate. | + +For any DCV method other than `EMAIL`, the token/record still needs to be published outside of +Command — this plugin passes your chosen method to MarkMonitor but doesn't automate DNS or HTTP token +publication. + +## Product IDs + +Each row is a MarkMonitor certificate product you can create a Command template for. + +| Product ID | Product family | +|---|---| +| `SslOvBasic` | OV SSL (basic) | +| `SslEvBasic` | EV SSL (basic) | +| `SslDvGeotrust` | GeoTrust DV SSL | +| `SslDvThawte` | Thawte DV SSL | +| `SslOvThawteWebserver` | Thawte OV Web Server SSL | +| `SslEvThawteWebserver` | Thawte EV Web Server SSL | +| `SslOvGeotrustTruebizid` | GeoTrust OV True BusinessID SSL | +| `SslEvGeotrustTruebizid` | GeoTrust EV True BusinessID SSL | +| `SslOvSecuresite` | DigiCert OV Secure Site SSL | +| `SslEvSecuresite` | DigiCert EV Secure Site SSL | +| `SslOvSecuresitePro` | DigiCert OV Secure Site Pro SSL | +| `SslEvSecuresitePro` | DigiCert EV Secure Site Pro SSL | + +Which of these your account can actually order — and any product-specific requirements — depends on +your MarkMonitor entitlements. Check with your MarkMonitor administrator if you're not sure. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..4fc4527 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,61 @@ +--- +title: Home +layout: default +nav_order: 1 +description: MarkMonitor AnyCA Gateway REST plugin for Keyfactor Command. +permalink: / +--- + +# MarkMonitor AnyCA Gateway REST plugin +{: .fs-9 } + +Issue, renew, revoke, and synchronize MarkMonitor SSL certificates directly from Keyfactor Command. +{: .fs-6 .fw-300 } + +[Get started](installation){: .btn .btn-primary .fs-5 .mb-4 .mb-md-0 .mr-2 } +[See configuration fields](configuration){: .btn .fs-5 .mb-4 .mb-md-0 } + +--- + +> **Pilot integration.** This plugin is a Keyfactor-supported pilot integration. If you run into an +> issue, open a ticket through the [Keyfactor Support Portal](https://support.keyfactor.com) or file +> it on [GitHub Issues](https://github.com/Keyfactor/markmonitor-caplugin/issues). + +## What it does + +Keyfactor Command manages certificate lifecycles centrally across every CA an organization uses. This +plugin plugs MarkMonitor's SSL certificate service into that picture, through Keyfactor's AnyCA +Gateway REST framework — so certificates from MarkMonitor show up, get renewed, and get revoked +alongside every other CA Command already manages. + +
+- **Bring MarkMonitor certificates into Command's inventory.** A sync job pulls in every certificate + order your MarkMonitor organization can see, chain included. +- **Request new certificates without leaving Command.** Enroll for any of MarkMonitor's SSL product + types — OV, EV, and DV, across GeoTrust, Thawte, and DigiCert Secure Site — from a Command + certificate template. +- **Renew and reissue.** A renewal places a fresh MarkMonitor order and retires the certificate it + replaces once the new one is ready. +- **Revoke on demand.** Revoke a MarkMonitor-issued certificate from Command, with a safety check that + refuses to touch a certificate belonging to a different MarkMonitor organization. +
+ +MarkMonitor's SSL certificates are issued by DigiCert, so everything you get through this plugin +chains up to a DigiCert root. + +## Where to go next + +| I want to... | Go to | +|---|---| +| Install the plugin and register MarkMonitor as a CA in Command | [Installation](installation) | +| Understand what MarkMonitor and Keyfactor Command each require before I start | [Overview](overview) | +| Look up a connector field, enrollment parameter, or product ID | [Configuration reference](configuration) | +| See what changed in the latest release | [Changelog](changelog) | + +## Support + +This plugin is supported by Keyfactor for Keyfactor customers. Keyfactor customers with a support +issue should open a ticket through the +[Keyfactor Support Portal](https://support.keyfactor.com). To report a bug or suggest an enhancement, +use [GitHub Issues](https://github.com/Keyfactor/markmonitor-caplugin/issues) or +[Pull Requests](https://github.com/Keyfactor/markmonitor-caplugin/pulls). diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..68bd056 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,80 @@ +--- +title: Installation +layout: default +nav_order: 3 +--- + +# Installation +{: .no_toc } + +Get the plugin installed and MarkMonitor registered as a certificate authority in Keyfactor Command. +{: .fs-6 .fw-300 } + +1. TOC +{: toc} + +--- + +## 1. Install the AnyCA Gateway REST + +If you haven't already, install the Keyfactor AnyCA Gateway REST following the +[official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/InstallIntroduction.htm). +This plugin requires **AnyCA Gateway REST v25.5.0 or later**. + +## 2. Trust the DigiCert issuing CA chain + +MarkMonitor's certificates are issued through DigiCert, so both the Gateway server and the Command +server need to trust DigiCert's root and intermediate certificates before you can enroll. + +Download the appropriate certificates from +[DigiCert's root certificate page](https://www.digicert.com/kb/digicert-root-certificates.htm) and +import them into: + +- **Trusted Root Certification Authorities** — for the root CA certificate +- **Intermediate Certification Authorities** — for any intermediate certificates + +on both the Gateway server and the Command server. + +## 3. Download and install the plugin + +1. On the server hosting the AnyCA Gateway REST, download and unzip the latest release from the + [Markmonitor-caplugin releases page](https://github.com/Keyfactor/markmonitor-caplugin/releases/latest). +2. Copy the unzipped directory (`net8.0` or `net10.0`, matching your Gateway's .NET version) into the + Gateway's Extensions folder: + + ```text + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net10.0\Extensions + ``` + + The folder name itself doesn't matter, as long as it's unique within `Extensions`. +3. Restart the AnyCA Gateway REST service. +4. Open the AnyCA Gateway REST portal and hover over the ⓘ icon near the top left to confirm the + Gateway recognizes the MarkMonitor plugin. + +## 4. Register MarkMonitor as a certificate authority + +Follow the +[official AnyCA Gateway REST documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) +to define a new Certificate Authority, using the **Gateway Registration** and **CA Connection** tabs. + +- On **Gateway Registration**, provide the DigiCert issuing CA certificate you imported in step 2 — + either as a local file path or from a Command certificate store (the store must already exist in + Command). +- On **CA Connection**, enter your MarkMonitor API key, service-account username/password, base URL, + and organization. Field-by-field details are in the + [Configuration reference](configuration#ca-connection-fields). + +## 5. Add the CA to Keyfactor Command and create templates + +1. Follow the + [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) + to add the Certificate Authority you just defined to Keyfactor Command, and import its templates. +2. Create one Command certificate template per MarkMonitor product you plan to enroll for — see the + full list in the [Configuration reference](configuration#product-ids). +3. (Command v12.3+) For each imported template, define enrollment fields for the parameters listed in + the [Configuration reference](configuration#template-enrollment-parameters) — all of them are + optional. + +You're ready to enroll. New requests, renewals, revocations, and the recurring sync will now flow +through MarkMonitor. diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..0d06e84 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,97 @@ +--- +title: Overview +layout: default +nav_order: 2 +--- + +# Overview +{: .no_toc } + +1. TOC +{: toc} + +--- + +## How the pieces fit together + +Three systems are involved, and each one plays a different role: + +- **Keyfactor Command** is where your team requests, tracks, and manages certificates. +- **The AnyCA Gateway REST** is Keyfactor's connector host — it's what actually talks to a + certificate authority on Command's behalf. This plugin is a DLL that the Gateway loads. +- **MarkMonitor** is the certificate authority itself, issuing SSL certificates that ultimately chain + up to a DigiCert root. + +When someone requests a certificate in Command, the request travels through the Gateway to this +plugin, which translates it into a MarkMonitor order. The reverse happens for revocation, and a +recurring sync job keeps Command's records of MarkMonitor certificates up to date automatically. + +## What you'll need + +Before installing, have these ready: + +- A MarkMonitor **API key** — contact MarkMonitor support to obtain one. +- A MarkMonitor **service account** (username and password) with permission to create certificate + orders. +- The **organization** name or ID that certificates should be ordered under. +- Keyfactor Command v12.0.0 or later. +- Keyfactor AnyCA Gateway REST v25.5.0 or later. +- Network access from the Gateway server to the MarkMonitor API, and the DigiCert root/intermediate + CA certificates trusted on both the Gateway server and the Command server. See + [Installation](installation) for the download link and where they go. + +## Two credentials, not one + +Most CAs Command connects to use a single API key or an OAuth client. MarkMonitor uses **two** +credentials together, and both are required: + +1. An **API key**, which identifies your MarkMonitor account. +2. A **service-account username and password**, which the plugin exchanges for a short-lived access + token behind the scenes. + +You'll enter all three — API key, username, password — when you configure the CA connector in +Command. There's nothing further to manage day to day: the plugin keeps itself authenticated and +refreshes the token automatically. + +## What you can do once it's set up + +- **Enroll** for any MarkMonitor SSL product your account is entitled to — OV, EV, and DV + certificates across the GeoTrust, Thawte, and DigiCert Secure Site product families. +- **Renew or reissue** a certificate. MarkMonitor doesn't have a true "renew in place" — so a renewal + places a new order, and once the replacement certificate is ready, the plugin retires the one it's + replacing. +- **Revoke** a certificate you no longer need. The plugin double-checks that the certificate actually + belongs to your configured MarkMonitor organization before revoking it. +- **Sync automatically.** On each sync, Command pulls in every certificate your organization can see + from MarkMonitor, complete with the full certificate chain. + +## Common questions + +**I enrolled, but the certificate didn't come back right away — is something wrong?** + +No — this is expected. MarkMonitor certificates go through Domain Control Validation (and sometimes +manual approval) before they're issued, so a brand-new request typically comes back in a pending +state rather than with a certificate attached. Command shows it as pending validation; the next +automatic sync picks up the certificate as soon as MarkMonitor issues it. + +**My ECC certificate request was rejected — why?** + +MarkMonitor requires ECC certificate requests to use a **named curve** (for example, P-256), not one +that spells out its curve parameters explicitly. The plugin checks for this before submitting your +request, so you'll see a clear error immediately instead of a silent failure on MarkMonitor's side. +Regenerating the CSR with a named curve resolves it. + +**I retried a request that seemed stuck, and got a warning about a duplicate submission — did it go +through twice?** + +No. If Command retries a request (for example, after a dropped connection), the plugin recognizes the +retry and returns the original result instead of placing a second order with MarkMonitor. + +**Revocation failed with an error about the certificate belonging to a different organization.** + +The plugin won't revoke a certificate unless it can confirm the certificate belongs to the MarkMonitor +organization your CA connector is configured for. Double-check that the connector's `OrgId` field +matches the organization that actually owns the certificate. + +Looking for a specific connector field or enrollment parameter? See the +[Configuration reference](configuration). diff --git a/docsource/CODEMAP.md b/docsource/CODEMAP.md new file mode 100644 index 0000000..bd08726 --- /dev/null +++ b/docsource/CODEMAP.md @@ -0,0 +1,195 @@ +# CODEMAP + +> **Purpose:** a fast orientation map of this repository for humans and AI agents. Read this first +> before diving into the source. +> +> **⚠️ Maintenance rule — any agent (or developer) that makes a code change MUST update this +> CODEMAP in the same change** if the change adds/removes/moves a source file of note, alters the +> architecture or a data flow, changes the build/test layout, or changes config/enrollment fields or +> product IDs. Keep it terse and accurate; a stale codemap is worse than none. This file is committed +> to the repo + +Last verified against the codebase: 2026-08-13. + +> To understand behavior, read this CODEMAP + the source it points to — **not** the root `README.md` +> (it's a generated artifact; parsing it wastes tokens). A CI check fails a PR that changes plugin +> source without updating this file (add the `skip-codemap` label to override). + +## What this is + +An AnyCA REST Gateway plugin that lets Keyfactor Command issue, revoke, and synchronize certificates +through the MarkMonitor SSL certificate API. It implements `IAnyCAPlugin` from +`Keyfactor.AnyGateway.Extensions` and is loaded as a DLL extension by the AnyCA Gateway REST host +process — **not** a standalone service. Plugin type: +`Keyfactor.Extensions.CAPlugin.MarkMonitor.MarkMonitorCAPlugin`. + +MarkMonitor's SSL API is backed by **DigiCert** (the only `provider` it supports). + +## Solution layout + +| Project | Role | +|---|---| +| `markmonitor-caplugin/` | The plugin. Dual-targets `net8.0` + `net10.0`; produces `MarkMonitorCAPlugin.dll` per TFM under `bin/Release//`, with `manifest.json` copied alongside (host discovery). | +| `markmonitor-caplugin.Tests/` | xUnit unit tests (mocked HTTP via `FakeHttpMessageHandler`; no live API). Targets `net8.0`. CI-safe; run by `.github/workflows/unit-tests.yml`. | +| `markmonitor-caplugin.IntegrationTests/` | xUnit live-API tests (real `MarkMonitorClient` calls: authenticate, list orgs, list certificate orders, RSA/ECC enroll). Targets `net8.0`; references `TestConsole` solely to reuse its `CsrGenerator`/`EmailAddressGenerator` helpers. Every test skips silently (no-op pass) when the `MARKMONITOR_*` env vars aren't set, so it's safe to run without creds — but when creds are present it hits the live API and creates/cleans-up (cancel, falling back to revoke) real orders. **Not** run by `unit-tests.yml` (that workflow targets the `.Tests` csproj directly); run manually via `.github/workflows/integration-tests.yml` (`workflow_dispatch` only) against the `markmonitor-integration` GitHub environment, which holds the `MARKMONITOR_*` secrets behind required-reviewer approval. | +| `TestConsole/` | Manual live smoke-test console app (creates REAL orders). Needs `MARKMONITOR_*` env vars. Not CI-safe. | + +## Key files (`markmonitor-caplugin/`) + +| File | Responsibility | +|---|---| +| `MarkMonitorCAConnector.cs` | `IAnyCAPlugin` entry point. Methods the Gateway host calls: `Initialize`, `Enroll`, `Revoke`, `Synchronize`, `GetSingleRecord`, `Ping`, `ValidateCAConnectionInfo` (field checks, then a live auth + org-list call via a transient client - never `_cachedClient`), `ValidateProductInfo` (cheap static `ProductID` enum check only - contact/group stay a no-op), `GetProductIds`, `GetCAConnectorAnnotations` / `GetTemplateParameterAnnotations`. `BuildClient(MarkMonitorConfig)` is the one place the client-construction argument list lives, shared by the cached-client path and `ValidateCAConnectionInfo`'s transient one. Holds the deserialized config and one lazily-built, cached `MarkMonitorClient` (`CreateAndAuthenticateClientAsync`). Contains the `RenewOrReissue` → revoke-prior logic (`ParseRenewalWindowDays` logs a warning, not a silent fallback, when the template param is present but invalid) and the `EnsureOrgNameConfigured` guard. | +| `Client/MarkMonitorClient.cs` | The MarkMonitor REST HTTP client. Owns: bearer-token auth (`X-API-KEY` header + username/password → token, cached with 30s early-expiry, double-checked locking); list pagination (`MarkMonitorPage.TotalPages`); CSR PEM/DER handling via BouncyCastle; ECC named-curve validation; the process-local enrollment dedup cache (5-min, keyed org\|product\|subject\|csr); `MarkMonitorCertificateStatusToCAStatus` mapping; `BuildErrorString` error parsing; org/contact/group resolution; `SendWithRetryAsync` (3-attempt retry with jittered exponential backoff on network failures/timeouts and 5xx/429, honoring `Retry-After` on 429 capped at 120s - **not** used for the order-create POST, the reissue PATCH, or `AuthenticateAsync`'s own POST: the first two risk creating a duplicate billable resource on an ambiguous failure, and auth runs inside `_authLock`, so retrying there would multiply how long *every* concurrent caller on the same cached client blocks, not just the degraded call); `HttpClient.Timeout` from the `TimeoutSeconds` config field (default 120s, clamped 1-120 - never above the pre-existing hardcoded default). | +| `MarkMonitorCAPluginConfig.cs` | CA-connection + enrollment-parameter schema, UI annotations/defaults, and canonical field-name constants: `ConfigConstants` (ApiKey, Username, Password=`"Password"`, BaseUrl, OrgId=`"OrgId"`, Enabled, TimeoutSeconds, PageSize, ForceCompleteSync, PickupRetries, PickupDelaySeconds) and `EnrollmentConfigConstants` (AdditionalEmails, MarkmonitorGroup, MarkmonitorContact, DCVMethod, comments, locale, provider, RenewalWindowDays). Also `ConfigurationValidationException`. | +| `MarkMonitorConfig.cs` | The deserialized CA-connection config type used at runtime. | +| `Models/` | Request/response DTOs (orders, organizations, contacts, groups, token) + `Enums.cs`. | +| `Models/Enums.cs` | `CertOrderTypes` (product IDs → API strings via `[Description]`), `OrderStatus`, `OrderActions`, `AlgorithmTypes`, `DomainControlValidationMethods`, `CertServerPlatforms`, and `EnumExtensions.GetDescription()`. | +| `Constants.cs` | Empty placeholder — real constants live in `MarkMonitorCAPluginConfig`. | + +## Core flows + +- **Auth:** `POST /auth/v1/auth/authenticate` with `X-API-KEY` header + `{username,password}` → bearer + token (cached, lazy refresh). No OAuth. +- **Enroll:** dedup-check → resolve org/contact/group → parse+validate CSR (reject ECC explicit + curve) → `POST /certs/v1/order`. Accepted orders usually return `CREATED` → + `EXTERNALVALIDATION` (pending DCV/approval) - MarkMonitor never issues synchronously from the + create-order call. `PollForIssuanceAsync` (inside the dedup reservation, so a folded-in concurrent + duplicate sees the polled result too) then polls its own single-attempt order fetch (not itself + retried, unlike most other GETs - see `Client/MarkMonitorClient.cs` above) up to `PickupRetries` + times (every `PickupDelaySeconds`) for a product whose DCV/approval resolves quickly, stopping + early on either issuance or a terminal non-issued status; `PickupRetries=0` (not the default) + skips polling entirely. Whatever order comes back (from polling or straight from order creation), + `EnrollCertificateAsync` downgrades a `GENERATED`-mapped status with a still-null cert body to + `INPROCESS` before returning - MarkMonitor's status can flip to issued a moment before the cert + body itself is populated, and reporting a false `GENERATED` with no certificate would be an + internally inconsistent result. `RenewOrReissue` places a new order then revokes the prior cert (via + `PriorCertSN` → `ICertificateDataReader`), but only when that prior cert's resolvable expiration + date is within its `RenewalWindowDays` template param (default 90) - if it's resolvable and + outside the window, the prior cert is left unrevoked and the request behaves like a plain new + issuance (unresolvable expiration falls back to always revoking, the pre-existing behavior). No + in-place renew/reissue in the enroll path. MarkMonitor issues CN ∪ order `dnsNames` - it does + **not** honor a CSR's own SAN extension as authoritative - so `BuildDnsNames` uses the Enroll + `san` dictionary (`Dns`/`dnsname` keys, case-insensitive) as the primary source; a SAN extension + embedded in the CSR itself is used only when `san` is `null` (Command never populated SAN data at + all), never when it's non-null even if empty, since a non-null dictionary means Command's own + enrollment pattern is authoritative for this request and a subscriber-generated CSR must not be + able to add domains beyond what that pattern authorized. Non-DNS SAN types (IP/email/URI) have no + MarkMonitor field and are dropped with a logged warning. +- **Revoke:** requires `OrgId`; resolves it to a GUID, fetches order, compares owning org as parsed + GUIDs, then `PATCH /certs/v1/order/{id}/revoke`. Reason code has no MarkMonitor field (logged only). + `CancelCertificateAsync` takes the same optional `orgName` parameter and runs the identical + cross-organization ownership check (shared via a private `EnsureOrderBelongsToOrganizationAsync` + helper) before `PATCH /certs/v1/order/{id}/cancel`. +- **Sync:** `GET /certs/v1/order` paginated (size from the `PageSize` config field, default 100), + map status, assemble full chain, buffer issued certs. Still **always a full listing** — + `lastSync`/date filtering not yet used — but each record is now checked against + `ICertificateDataReader` and skipped if Command already has it at both the same mapped status + *and* the same expiration date (skip-unchanged) - comparing only status would otherwise treat an + out-of-band MarkMonitor reissue of the same order ID (which round-trips DIGI_ISSUED → + DIGI_REISSUE_PENDING → DIGI_ISSUED) as unchanged if a sync happens to straddle just the before/ + after of that round-trip; `ForceCompleteSync` (config) or Command's own `fullSync` flag bypasses + the optimization entirely. A bad individual record is logged + counted + skipped rather than + aborting the sync, but an error rate over 25% (once ≥50 records observed) aborts the whole sync as + a circuit breaker (checked per-record via `Interlocked` counters, but only enforced at the end of + the current page, not the instant it crosses - see below). Records within one page are processed + with bounded concurrency (`Parallel.ForEachAsync`, max 10 at a time) rather than sequentially, + since the skip-unchanged check's local `ICertificateDataReader` round-trips would otherwise + serialize (and block the next MarkMonitor page fetch behind) a large sync's entire record count. + +### MarkMonitor endpoints + +`POST /auth/v1/auth/authenticate` · `GET|POST /certs/v1/order` · `GET /certs/v1/order/{id}` · +`PATCH /certs/v1/order/{id}/{revoke|cancel|reissue}` · `GET /certs/v1/organization[/{id}]` · +`GET /auth/v1/group`. (cancel/reissue exist in the client but are **not** wired into `IAnyCAPlugin`.) + +### Status mapping (`OrderStatus` → `EndEntityStatus`) + +`CREATED`→EXTERNALVALIDATION · `DIGI_ISSUED`→GENERATED · `DIGI_REVOKED`→REVOKED · +pending set (`DIGI_PENDING`/`DIGI_PROCESSING`/`DIGI_REISSUE_PENDING`/`DIGI_WAITING_PICKUP`/ +`REISSUE_PENDING`/`DIGI_NEEDS_APPROVAL`/`REISSUE_REQUEST_PENDING`)→INPROCESS · +`DIGI_FAILED`/`DIGI_REISSUE_FAILED`→FAILED · +`DIGI_CANCELED`/`DIGI_REJECTED`/`DIGI_EXPIRED`/`DIGI_NEEDS_CSR`→CANCELLED · else→FAILED. + +## Gotchas / non-obvious behavior + +- **Two credentials, used together:** API key (header) *and* service account (token). Missing either + fails auth. +- **ECC CSRs must use a named curve** — explicit curve params are silently failed by MarkMonitor, so + the plugin rejects them up front (`ValidateEccCsrUsesNamedCurve`). +- **DSA is not supported** for CSRs. +- **Enrollment dedup cache** is process-local and short-lived — guards Command retries only, not a + durable store. Failed attempts are not cached. +- **`OrgId`** accepts a name or GUID; blank `OrgId` skips the revoke ownership check for some paths + but `Revoke` refuses to run without it. +- **Org name resolution requires an exact (case-insensitive) name match** — `ResolveOrganizationAsync`/ + `ResolveOrganizationIdAsync` filter MarkMonitor's `/certs/v1/organization?name=` search results down + to an exact match rather than taking the first result, since that endpoint's own matching semantics + aren't guaranteed to be exact (a configured name that's a substring of another org's name must not + silently resolve to the wrong org — [#9](../../issues/9)). +- **Order IDs** are validated as GUIDs before being interpolated into URLs. +- **Revocation reason** cannot be forwarded to MarkMonitor (no schema field). +- **`ValidateProductInfo`'s contact/group handling stays a no-op** — resolved and defaulted at enroll + time, not validated at template save (a deliberate tradeoff documented in the method's own + comment). It does now reject an unparseable `ProductID` (a cheap static enum check, no live call). +- **`ValidateCAConnectionInfo` makes a live MarkMonitor call** (authenticate + list one organization) + after its field checks pass, via a transient client built from the connectionInfo being saved - + never `_cachedClient`. A constructor-injected client (the same test seam every other method uses) + is reused as-is and left undisposed, rather than building a second transient one, so tests don't + need a live API. + +## Build / test / deploy + +```shell +dotnet build markmonitor-caplugin.sln -c Release # build (both TFMs) +dotnet test markmonitor-caplugin.sln -c Release # unit + integration tests (integration tests no-op skip without MARKMONITOR_* env) +dotnet test markmonitor-caplugin.IntegrationTests -c Release # live-API tests only (skips without MARKMONITOR_* env; creates/cleans up real orders when creds present) +dotnet run --project TestConsole # live smoke test (needs MARKMONITOR_* env; destructive) +``` + +Deploy: copy a TFM output dir into the Gateway `Extensions` folder, restart the AnyCA Gateway REST +service. + +## Docs & metadata to keep in sync + +- `docs/` — the customer-facing GitHub Pages site (Jekyll + the `just-the-docs` remote theme, + Keyfactor-branded via `docs/_sass/color_schemes/keyfactor.scss`), published from `main`/`docs` with + no Actions workflow. **Not** generated from `docsource/` — its six pages (`index.md`, + `overview.md`, `installation.md`, `configuration.md`, `architecture.md`, `changelog.md`) carry + independently-written, lighter-tone copy aimed at customers, and its own copies of the screenshots + under `docs/assets/images/` (copied from `docsource/images/`, not symlinked). When a connector + field, enrollment parameter, or product ID changes, update both `docsource/configuration.md` (feeds + `README.md`) **and** `docs/configuration.md` (feeds the Pages site); when a lifecycle diagram + changes, update both `docsource/architecture.md` **and** `docs/architecture.md` the same way — each + pair is two separate, hand-maintained copies by design, not one generating the other, so it's easy + for one to drift stale while the other moves on (this happened once already — `docs/architecture.md` + sat frozen through a `PickupRetries` polling change and a live connector-validation check landing in + `docsource/architecture.md`/`DEVELOPMENT.md` — diff the two by eye when touching either). + `docs/changelog.md` only links out to the root `CHANGELOG.md`; Jekyll's build root is `docs/`, so it + cannot `include_relative` a file outside that directory. Preview it locally before publishing with + `just docs-preview` (needs Docker; runs Jekyll via the `jekyll/jekyll` image, no local Ruby + toolchain required) at `http://localhost:4000/markmonitor-caplugin/`; `docs/Gemfile` pins the + plugins (`jekyll-remote-theme`, `jekyll-seo-tag`, `jekyll-include-cache`) `just-the-docs` needs that + aren't in the base image. +- `docsource/configuration.md`, `docsource/overview.md`, and `docsource/architecture.md` — + source-of-truth, customer-facing doc fragments, plus this `CODEMAP.md`. Root `README.md` is + **generated by doctool** (`Keyfactor/doctooldotnet`, cloned at `~/RiderProjects/doctooldotnet`) + from `docsource/configuration.md` + `integration-manifest.json` — never hand-edit `README.md`; edit + the docsource fragment and regenerate: + `cd ~/RiderProjects/doctooldotnet && just build && just docs markmonitor-caplugin`. + `configuration.md` ends with `{% include 'architecture.md' %}` (matching the pattern other AnyCA + plugins in this org use, e.g. certinext-caplugin) so the diagrams/tables in `architecture.md` are + inlined into the generated README as its `## Architecture` section — edit `architecture.md` + directly, not a copy pasted into `configuration.md`. `overview.md` stays standalone (its + Troubleshooting section is not pulled into the README by anything). Images referenced from + `configuration.md` must use `docsource/images/...`. (`readme_source.md` is a stale placeholder and + is not used.) +- Root `DEVELOPMENT.md` is the hand-maintained developer guide (solution layout, build/test, + `TestConsole` smoke-testing) — like `LICENSE`, it is **not** part of the doctool pipeline and is + edited directly. Its own `## Architecture` section is a *second, deeper tier* of the same diagrams + in `docsource/architecture.md` — same lifecycle operations, but annotated with real method/class + names for developers, and it deliberately cross-references `README.md`'s Component Overview diagram + and Order Status Mapping/API Endpoint Reference tables rather than re-embedding them. When a + lifecycle flow changes, update **both** tiers: `docsource/architecture.md` (plain-English, customer + tier) and `DEVELOPMENT.md`'s `## Architecture` section (method-level, developer tier). +- `integration-manifest.json` — catalog metadata (`product_ids`, `ca_plugin_config`, + `enrollment_config`). Keep aligned with `MarkMonitorCAPluginConfig.cs` and `CertOrderTypes`. diff --git a/docsource/architecture.md b/docsource/architecture.md new file mode 100644 index 0000000..fa16cf5 --- /dev/null +++ b/docsource/architecture.md @@ -0,0 +1,280 @@ +## Architecture + +This document describes how the MarkMonitor AnyCA Gateway REST plugin integrates with Keyfactor Command and the MarkMonitor SSL certificate API. It covers the primary certificate lifecycle operations — synchronization, enrollment, and revocation — and how the plugin routes each through the MarkMonitor REST API. + +## Component Overview + +``` +┌─────────────────────────────────────────────────────────┐ +│ Keyfactor Command │ +│ │ +│ Certificate Enrollment · Revocation · Sync Jobs │ +└────────────────────────────┬─────────────────────────────┘ + │ + AnyCA Gateway REST + (plugin host process) + │ +┌────────────────────────────▼─────────────────────────────┐ +│ The MarkMonitor plugin │ +│ │ +│ Translates Keyfactor operations into MarkMonitor API │ +│ calls and maps responses back to Command's data model. │ +└────────────────────────────┬─────────────────────────────┘ + │ HTTPS · Bearer token + X-API-KEY + │ +┌────────────────────────────▼─────────────────────────────┐ +│ MarkMonitor REST API (DigiCert) │ +│ │ +│ /auth/v1/auth/authenticate /certs/v1/order │ +│ /certs/v1/organization /auth/v1/group │ +└──────────────────────────────────────────────────────────┘ +``` + +## Request Authentication + +MarkMonitor uses two credentials together. The API key is sent as the `X-API-KEY` header on the authentication request; the service-account username and password are POSTed to `/auth/v1/auth/authenticate`, which returns a bearer token and its lifetime. Every subsequent request carries that token in an `Authorization: Bearer` header. + +``` +Authorization: Bearer where token ← POST /auth/v1/auth/authenticate + headers: X-API-KEY: + body: { username, password } +``` + +The token is cached for the lifetime of the plugin's API client and refreshed automatically shortly before it expires — a normal enrollment, sync, or revoke call never has to authenticate explicitly. There is no OAuth client-credentials mode. + +## Certificate Identifiers + +MarkMonitor identifies each order by a **GUID order ID**. That order ID is what the plugin stores in Keyfactor Command as the request identifier, and it is the identifier used for every post-enrollment operation (status check, revoke). Any order ID arriving from Command is validated as a GUID before use. + +The configured organization (`OrgId`) may be supplied either as a friendly **name** or as a **GUID** — the plugin resolves a name to its GUID by listing organizations and matching exactly (case-insensitive); a value that already parses as a GUID is used directly. + +--- + +## Gateway Startup + +When the AnyCA Gateway loads the plugin, it deserializes the CA connection configuration first. The API client itself isn't built until the first operation needs it, and authentication is lazy on top of that — it doesn't happen until the Gateway calls `Ping()` to verify connectivity. + +```mermaid +sequenceDiagram + participant GW as AnyCA Gateway + participant Plugin as MarkMonitor plugin + participant API as MarkMonitor API + + GW->>Plugin: Initialize(configProvider, certificateDataReader) + Plugin->>Plugin: Deserialize CA connection config + Note over Plugin: Client not built yet (lazy) + GW->>Plugin: Ping() + Plugin->>Plugin: Build & cache the API client (first use only) + Plugin->>API: Authenticate (API key + username/password) + API-->>Plugin: Bearer token + Plugin->>API: List organizations + API-->>Plugin: Organizations + Plugin-->>GW: Ping OK (auth works and at least one org exists) +``` + +--- + +## Synchronization + +Keyfactor Command periodically synchronizes its certificate inventory with MarkMonitor. The plugin retrieves all certificate orders visible to the configured organization, page by page, and imports issued certificates — along with their full certificate chain — into Command. + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as MarkMonitor plugin + participant API as MarkMonitor API + + CMD->>Plugin: Start synchronization + Plugin->>API: Authenticate with MarkMonitor + + loop Retrieve one page of orders at a time + Plugin->>API: List certificate orders + API-->>Plugin: Page of order records + + loop For each order on the page + alt Order has no certificate yet + Plugin->>Plugin: Skip for this sync + else Order has a certificate + Plugin->>Plugin: Map the MarkMonitor status to a Keyfactor status + alt Unchanged since Command's last known status (and not forced) + Plugin->>Plugin: Skip re-emission + else New or changed + Plugin->>Plugin: Assemble the full certificate chain + Plugin->>CMD: Add certificate to Command's inventory + end + end + end + end + + Plugin-->>CMD: Synchronization complete (emitted / skipped-unchanged / errored counts) +``` + +> A record that fails to process is logged, counted, and skipped rather than aborting the sync - but +> the sync aborts outright if more than 25% of records fail once at least 50 have been observed. + +> The current implementation always performs a full listing of orders on each sync, rather than only +> retrieving certificates that changed since the last sync - `PageSize` optimizes the *mitigation*, not +> the listing itself: each order is compared against what Command already has for that request ID, and +> skipped (not re-emitted) when the status is unchanged, unless `ForceCompleteSync` is enabled or +> Command requests a full sync. Orders that have not yet produced a certificate are simply skipped for +> that sync rather than treated as an error. + +--- + +## Certificate Enrollment + +When a requester submits a certificate request through Keyfactor Command, the plugin translates it into a MarkMonitor order: it resolves the organization, contact, and (optional) group; validates and normalizes the CSR; and submits the order. MarkMonitor never issues synchronously from the create-order call - a newly submitted order always comes back pending (typically `CREATED`), since Domain Control Validation (and, in some environments, manual approval) is required first. + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as MarkMonitor plugin + participant API as MarkMonitor API + + CMD->>Plugin: Submit certificate request + Plugin->>API: Authenticate with MarkMonitor + Plugin->>Plugin: Check for a duplicate in-flight request + + alt An identical request was already submitted / just completed + Plugin-->>CMD: Return the original result (no duplicate order placed) + else New request + Plugin->>API: Resolve organization, contact, and group + Plugin->>Plugin: Validate and normalize the CSR + Plugin->>API: Submit the certificate order + API-->>Plugin: Order accepted — order ID and status + + opt Not yet issued and PickupRetries > 0 + loop Up to PickupRetries times, every PickupDelaySeconds + Plugin->>API: Poll the order + API-->>Plugin: Current status + end + end + + alt Renewal/Reissue request + Plugin->>API: Revoke the certificate being replaced + end + + Plugin-->>CMD: Enrollment result (order ID, current status) + end +``` + +For a product whose DCV/approval resolves quickly, this polling lets the issued certificate come back in the same enrollment call instead of always waiting for the next sync; `PickupRetries=0` disables it and restores the always-returns-pending behavior. + +> A concurrent duplicate request folded into this same in-flight reservation (the "identical request +> already submitted" branch above) receives the polled result too, not just the original pending +> status - the fold happens after polling completes, not before. + +### Renewal / Reissue + +MarkMonitor has no in-place "renew" enrollment endpoint through this plugin, so a Renewal/Reissue request always places a brand-new order. Once the replacement certificate has been created successfully, the plugin revokes the certificate it is replacing — but only if that certificate is within its `RenewalWindowDays` template parameter (default 90) of expiring. + +```mermaid +flowchart TD + A([Renewal / reissue enrollment]) --> B[Place a new MarkMonitor order] + B --> C{"Prior certificate
identified?"} + C -- No --> D([Treat as a new issuance - done]) + C -- Yes --> E["Resolve the prior order"] + E --> F{"Within the configured
renewal window?"} + F -- No --> G([Leave prior certificate unrevoked]) + F -- Yes --> H["Revoke the prior order"] + H --> I([New cert delivered, prior revoked]) + D --> I + G --> I +``` + +If the certificate being replaced can't be identified, or still has substantial life left (outside the renewal window), the request is simply treated as a new issuance and the prior certificate is left alone — a failure to revoke the old certificate never blocks delivery of the new one either. + +--- + +## Revocation + +When a certificate is revoked in Keyfactor Command, the plugin confirms that the target order belongs to the organization the CA connector is configured for before calling MarkMonitor's revoke operation. + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as MarkMonitor plugin + participant API as MarkMonitor API + + CMD->>Plugin: Revoke certificate + Plugin->>API: Authenticate with MarkMonitor + Plugin->>API: Look up the order's owning organization + + alt Order belongs to a different organization + Plugin-->>CMD: Error — refusing to revoke (different organization) + else Order belongs to the configured organization + Plugin->>API: Revoke the order + API-->>Plugin: Revocation confirmed + Plugin-->>CMD: Certificate marked revoked + end +``` + +> MarkMonitor's revoke operation has no field for a revocation reason code, so the reason supplied by +> Keyfactor Command cannot be forwarded to MarkMonitor. + +--- + +## Connector Validation + +When an administrator saves or edits the CA connector, the plugin checks the supplied configuration before the connector can be saved in an enabled state. + +```mermaid +flowchart TD + A([Save connector configuration]) --> B{"API Key, Username,
Password all present?"} + B -- Missing --> E([Validation error shown to administrator]) + B -- Present --> C{"Base URL starts with https://
(or blank → default)?"} + C -- Not https --> E + C -- OK --> D{"Organization present?"} + D -- Missing --> E + D -- Present --> N{"Enabled?"} + N -- No --> F([Connector saved]) + N -- Yes --> G{"Authenticate with the
submitted credentials"} + G -- Fails --> E + G -- Succeeds --> H{"At least one organization
visible?"} + H -- No / fails --> E + H -- Yes --> F +``` + +After the field checks above pass - and only if the connector is being saved enabled - the plugin also places a live call to MarkMonitor: it authenticates with the submitted (not yet saved) credentials and confirms at least one organization is visible, using a transient client built from exactly what's about to be saved — never the connector's already-cached client, which could be validating stale credentials. Saving with `Enabled` set to `false` skips this live check entirely, preserving that field's own documented purpose: creating the connector before real credentials are available. + +--- + +## Order Status Mapping + +MarkMonitor order statuses are mapped to Keyfactor statuses as follows: + +| MarkMonitor order status | Keyfactor status | +|---|---| +| `DIGI_PENDING`, `DIGI_PROCESSING`, `DIGI_REISSUE_PENDING`, `DIGI_WAITING_PICKUP`, `REISSUE_PENDING`, `DIGI_NEEDS_APPROVAL`, `REISSUE_REQUEST_PENDING` | `INPROCESS` | +| `CREATED` | `EXTERNALVALIDATION` (accepted, awaiting DCV/issuance) | +| `DIGI_ISSUED` | `GENERATED` (issued) | +| `DIGI_REVOKED` | `REVOKED` | +| `DIGI_FAILED`, `DIGI_REISSUE_FAILED` | `FAILED` | +| `DIGI_CANCELED`, `DIGI_REJECTED`, `DIGI_EXPIRED`, `DIGI_NEEDS_CSR` | `CANCELLED` | +| *(null/empty or unrecognized status)* | `FAILED` | + +> A freshly-submitted order (`CREATED`) is deliberately mapped to `EXTERNALVALIDATION` rather than a +> failure status — this means the order was accepted by MarkMonitor and is simply awaiting DCV or +> issuance. Once the order reaches `DIGI_ISSUED`, the next synchronization imports the certificate. + +## API Endpoint Reference + +The plugin calls the following MarkMonitor API endpoints. This is useful for firewall and network connectivity planning. + +| Operation | MarkMonitor API endpoint | +|---|---| +| Authenticate / obtain bearer token | `POST /auth/v1/auth/authenticate` (with `X-API-KEY` header) | +| List certificate orders (sync) | `GET /certs/v1/order` (paginated via `page`/`size`) | +| Get a single order | `GET /certs/v1/order/{orderId}` | +| Place a new order (enroll) | `POST /certs/v1/order` | +| Revoke a certificate | `PATCH /certs/v1/order/{orderId}/revoke` | +| Cancel an order | `PATCH /certs/v1/order/{orderId}/cancel` | +| Reissue a certificate | `PATCH /certs/v1/order/{orderId}/reissue` | +| List organizations | `GET /certs/v1/organization` (paginated) | +| Get an organization | `GET /certs/v1/organization/{orgId}` | +| List groups | `GET /auth/v1/group` (paginated) | + +> The cancel and reissue endpoints exist in the client but are not currently invoked by the +> `IAnyCAPlugin` operations — a Renewal/Reissue enrollment is implemented as a new order followed by +> revoking the prior certificate (see [Renewal / Reissue](#renewal--reissue)), not MarkMonitor's own +> reissue action. diff --git a/docsource/configuration.md b/docsource/configuration.md new file mode 100644 index 0000000..9c1b7c2 --- /dev/null +++ b/docsource/configuration.md @@ -0,0 +1,173 @@ +## Overview + +The MarkMonitor AnyCA Gateway REST plugin extends the certificate lifecycle capabilities of the +MarkMonitor SSL certificate service to Keyfactor Command via the Keyfactor AnyCA Gateway REST. It +implements `IAnyCAPlugin` and is loaded as a DLL extension by the AnyCA Gateway REST host process — +it is not a standalone service. The plugin supports the following capabilities: + +* CA Synchronization: + * Downloads all certificate orders visible to the configured MarkMonitor organization and + imports the issued certificates (and their chains) into Keyfactor Command. + * Orders that have not yet produced a certificate are mapped to the appropriate pending/failed + status rather than imported as certificates. +* Certificate Enrollment for the SSL product types MarkMonitor exposes: + * Submits a new MarkMonitor certificate order per product type. + * A process-local dedup guard prevents Keyfactor Command retries from creating duplicate orders. +* Renewal / Reissue: + * MarkMonitor has no in-place "renew" enrollment endpoint through this plugin, so a + `RenewOrReissue` enrollment places a **new** order and then revokes the prior certificate once + the replacement has been created successfully. +* Certificate Revocation: + * Revokes a previously issued certificate, with a cross-organization ownership check that refuses + to revoke an order belonging to a different organization than the one the CA connector is + configured for. + +MarkMonitor's SSL API is backed by DigiCert (the only certificate `provider` its API currently +supports), so issued certificates chain up to DigiCert roots. + +## Requirements + +- A MarkMonitor **API Key** (contact MarkMonitor support to obtain one). +- A MarkMonitor **service account** (username and password) with permission to create certificate + orders. +- The **organization** name or ID (GUID) the certificates will be ordered under. +- Keyfactor Command >= v12.0.0. +- AnyCA Gateway REST >= v25.5.0. +- Network connectivity from the AnyCA Gateway host to the MarkMonitor API base URL, and trust of the + DigiCert issuing CA chain on both the gateway host and the Command server (see + [Gateway Registration](#gateway-registration)). + +## MarkMonitor API Setup + +MarkMonitor requires **two** credentials that are used together (there is no OAuth mode): + +1. **API Key** — sent as the `X-API-KEY` header on the authentication request. Enter it in the + `ApiKey` connector field (masked in the Command UI). +2. **Service-account username and password** — POSTed to `/auth/v1/auth/authenticate`, which returns + a short-lived bearer token used on all subsequent calls. Enter them in the `Username` and + `Password` connector fields (the password is masked in the UI). + +Contact your MarkMonitor administrator to provision the API key and a service account with order +permissions, and to confirm the correct API base URL and organization name/ID for your environment. + +## Gateway Registration + +In order to enroll for certificates the Keyfactor Command server must trust the issuing CA chain. +MarkMonitor's default issuing CA (`provider`) is **DigiCert** — download and import the appropriate +certificate chain from to the AnyCA +Gateway host and Command server. + +Once the necessary files are copied to the appropriate locations and the AnyCA Gateway REST is up and +running, navigate to the AnyCA Gateway REST portal and configure the CA. + +### Using file path for issuing CA certificate +![gateway_registration_local_file.png](docsource/images/gateway_registration_local_file.png) + +### Using Keyfactor Command certificate store for issuing CA certificate +> **⚠️ Warning:** The cert store must already exist in Keyfactor Command. + +![gateway_registration_store.png](docsource/images/gateway_registration_store.png) + +## Certificate Profiles + +The AnyCA Gateway REST portal requires a **certificate profile** for each MarkMonitor product you +intend to enroll against (see [Product IDs](#product-ids)) — this is separate from both the CA +connector configuration below and the Command certificate templates created afterward. Profiles can +be created by hand in the gateway portal, or with the helper script this repo ships: + +```shell +just register-gateway-profiles # create/update one profile per product, idempotent +just register-gateway-profiles 1 # dry run — preview only, no gateway calls +``` + +The script authenticates to the gateway's admin API (OAuth2 client-credentials, a bearer token, or a +pasted browser session cookie) and reads the product list from `integration-manifest.json`, so it +stays in sync with the product IDs above without hand-entering each one. See +`scripts/register-gateway-profiles.sh` and `scripts/lib/gateway-auth.sh` for the required environment +variables, or [Gateway Certificate Profile Quickstart](docsource/gateway-profile-quickstart.md) for a +walkthrough of each supported auth method. + +## CA Connection Configuration + +The following fields are presented in the AnyCA Gateway REST portal (and the Keyfactor Command +Management Portal) when creating or editing the MarkMonitor CA connector. All fields except `Enabled` +must be provided before the connector can be saved in an enabled state. + +![gateway_ca_configuration.png](docsource/images/gateway_ca_configuration.png) + +| Field | Required / Optional | Masked | Default | Description | +|---|---|---|---|---| +| `ApiKey` | Required | Yes | *(none)* | The MarkMonitor API key, sent as the `X-API-KEY` header when authenticating. | +| `Username` | Required | No | *(none)* | Username for the MarkMonitor API service account. | +| `Password` | Required | Yes | *(none)* | Password for the MarkMonitor API service account. | +| `BaseUrl` | Required | No | `https://api.markmonitor.com` | The MarkMonitor API base URL. Must start with `https://` — credentials and the bearer token are sent to it. | +| `OrgId` | Required | No | *(none)* | The MarkMonitor organization to use for API calls. Accepts either the organization **name** (e.g. `MarkMonitor`) or its **ID in GUID format**. Used to scope enrollment and to verify ownership on revoke. | +| `Enabled` | Optional | No | `true` | Enables or disables gateway functionality. Disable to allow the CA to be created before configuration information is available. | +| `TimeoutSeconds` | Optional | No | `120` | The HTTP request timeout, in seconds, for calls to the MarkMonitor API. Clamped to 1-120. | +| `PageSize` | Optional | No | `100` | The number of certificate orders requested per page during synchronization. Clamped to 1-500. | +| `ForceCompleteSync` | Optional | No | `false` | When `true`, bypasses the skip-unchanged synchronization optimization and re-emits every order on every sync. | +| `PickupRetries` | Optional | No | `5` | How many times `Enroll` polls a freshly-created order for issuance before returning it in its still-pending state. `0` disables polling. Clamped to 0-20. | +| `PickupDelaySeconds` | Optional | No | `10` | The delay, in seconds, between issuance pickup polls. Clamped to 0-60. | + +> **Note:** Credentials are stored in Keyfactor Command's encrypted gateway configuration. `ApiKey` +> and `Password` are masked in the UI and are never written to logs by the plugin. + +## Certificate Template Creation Step + +A certificate template must be created in Keyfactor Command for each MarkMonitor product type you +want to enroll. One template is required per product type (see [Product IDs](#product-ids)). Below is +an example of a template for a GeoTrust DV SSL certificate. For more on certificate product types, +contact your MarkMonitor administrator or support. + +![gateway_template.png](docsource/images/gateway_template.png) + +## Template Enrollment Parameters + +Custom enrollment parameters can be added to templates in Keyfactor Command after they have been +imported from the AnyCA Gateway. **All parameters are optional** and are read case-insensitively at +enrollment time. + +![template_enrollment_params.png](docsource/images/template_enrollment_params.png) + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `AdditionalEmails` | String | *(empty)* | Zero or more comma-separated email addresses that MarkMonitor will send the issued certificate to. Spaces are also treated as separators. | +| `MarkmonitorGroup` | String | *(none)* | The name or GUID of a MarkMonitor group to associate with the order. Matched by GUID or exact (case-insensitive) name against the account-wide group list. A blank or unresolved value is omitted (best-effort). | +| `MarkmonitorContact` | String | *(org default)* | The GUID, email, or `First Last` name of a MarkMonitor contact within the organization. Falls back to the organization's default contact if not specified or not resolvable. | +| `DCVMethod` | String | `EMAIL` | Domain Control Validation method. Valid values: `EMAIL`, `DNS_CNAME_TOKEN`, `HTTP_TOKEN`, `DNS_TXT_TOKEN`. An invalid value logs a warning and falls back to `EMAIL`. | +| `comments` | String | `Requested via Keyfactor Command` | Free-text comments attached to the MarkMonitor order. | +| `locale` | String | `en` | Locale for the MarkMonitor order. | +| `provider` | String | `DIGICERT` | The certificate provider for the order. `DIGICERT` is currently the only provider the MarkMonitor API supports. | +| `RenewalWindowDays` | Number | `90` | For a `RenewOrReissue` enrollment, how many days before its expiration the prior certificate must be within before it's revoked after the replacement issues. Outside that window, the prior certificate is left unrevoked and the request is treated like a plain new issuance. An invalid (non-numeric or non-positive) value falls back to the default. | + +> **Note on DCV:** The plugin passes the selected `DCVMethod` to MarkMonitor but does not itself +> automate DNS/HTTP token publication. For `EMAIL` (the default), MarkMonitor falls back to the +> domain/organization's registered DCV contacts; the plugin sends an empty `dcvEmails` list. + +## Product IDs + +`GetProductIds()` returns the names of the `CertOrderTypes` enum. The **Product ID** column is the +value Keyfactor Command sees and stores; the plugin maps it to the **MarkMonitor cert type** string +(the enum's `[Description]`) when placing an order. Adding a new MarkMonitor product means adding an +enum member with the matching `[Description]` — not editing a separate list. + +| Product ID (Command) | MarkMonitor cert type | Typical product family | +|---|---|---| +| `SslOvBasic` | `SSL_OV_BASIC` | OV SSL (basic) | +| `SslEvBasic` | `SSL_EV_BASIC` | EV SSL (basic) | +| `SslDvGeotrust` | `SSL_DV_GEOTRUST` | GeoTrust DV SSL | +| `SslDvThawte` | `SSL_DV_THAWTE` | Thawte DV SSL | +| `SslOvThawteWebserver` | `SSL_OV_THAWTE_WEBSERVER` | Thawte OV Web Server SSL | +| `SslEvThawteWebserver` | `SSL_EV_THAWTE_WEBSERVER` | Thawte EV Web Server SSL | +| `SslOvGeotrustTruebizid` | `SSL_OV_GEOTRUST_TRUEBIZID` | GeoTrust OV True BusinessID SSL | +| `SslEvGeotrustTruebizid` | `SSL_EV_GEOTRUST_TRUEBIZID` | GeoTrust EV True BusinessID SSL | +| `SslOvSecuresite` | `SSL_OV_SECURESITE` | DigiCert OV Secure Site SSL | +| `SslEvSecuresite` | `SSL_EV_SECURESITE` | DigiCert EV Secure Site SSL | +| `SslOvSecuresitePro` | `SSL_OV_SECURESITE_PRO` | DigiCert OV Secure Site Pro SSL | +| `SslEvSecuresitePro` | `SSL_EV_SECURESITE_PRO` | DigiCert EV Secure Site Pro SSL | + +> **Note:** The "typical product family" column is descriptive. Which product types your MarkMonitor +> account may actually order — and any per-product required fields — depend on your account's +> entitlements. Confirm availability with your MarkMonitor administrator. + +{% include 'architecture.md' %} diff --git a/docsource/gateway-profile-quickstart.md b/docsource/gateway-profile-quickstart.md new file mode 100644 index 0000000..1383fc1 --- /dev/null +++ b/docsource/gateway-profile-quickstart.md @@ -0,0 +1,121 @@ +# Gateway Certificate Profile Quickstart + +This is a standalone walkthrough for `scripts/register-gateway-profiles.sh`, the helper that +creates/updates the AnyCA Gateway REST **certificate profiles** this plugin needs (one per +MarkMonitor product in `integration-manifest.json`). It's idempotent — safe to re-run any time the +product list changes. See [Certificate Profiles](configuration.md#certificate-profiles) for where +this fits in the overall setup flow. + +## Prerequisites + +- `bash`, `curl`, and `jq` on the machine running the script. +- [`just`](https://github.com/casey/just) (optional) — the repo's `justfile` wraps the script; you + can also invoke it directly. +- The AnyCA Gateway REST instance up and reachable, and one of the three credentials below for its + admin API. + +## 1. Set the gateway host + +```shell +GATEWAY_HOST=gateway.example.com +``` + +If your gateway hosts multiple instances, each instance is mounted at its own base path (e.g. +`/markmonitor-0` instead of the default `/AnyGatewayREST`) — check the Portal or Swagger URL for the +instance you're targeting and set it explicitly: + +```shell +GATEWAY_BASE_PATH=/markmonitor-0 +``` + +## 2. Choose an auth method + +`scripts/lib/gateway-auth.sh` supports three ways to authenticate to the gateway's admin API. Set +**one** of these — the script checks them in the order below. + +### Option A — browser session cookie + +Fastest for a one-off manual run: log into the Portal, open dev tools → Network, copy the `Cookie` +header value from any request, and set it directly. + +```shell +GATEWAY_HOST=gateway.example.com +GATEWAY_COOKIE='.AspNetCore.Cookies=CfDJ8...' +``` + +### Option B — pre-obtained bearer token + +If you already have a token (e.g. from a prior `curl` against your identity provider), skip the +OAuth2 round-trip and use it directly. + +```shell +GATEWAY_HOST=gateway.example.com +GATEWAY_TOKEN=eyJhbGciOi... +``` + +### Option C — OAuth2 client credentials (recommended for automation) + +The script fetches a fresh token itself on every run — best for CI or scheduled use where a pasted +cookie/token would go stale. + +```shell +GATEWAY_HOST=gateway.example.com +TOKEN_URL=https://idp.example.com/oauth2/token +OIDC_CLIENT_ID=markmonitor-gateway-admin +OIDC_CLIENT_SECRET=*** +# Optional, defaults to keyfactor-anyca-gateway: +GATEWAY_SCOPE=keyfactor-anyca-gateway +``` + +## Putting it in a `.env` file + +The script auto-sources a `.env` in the repo root, so for repeated local use it's easiest to drop +whichever option's variables in there instead of exporting them every session: + +```shell +# .env (repo root, gitignored) +GATEWAY_HOST=gateway.example.com +TOKEN_URL=https://idp.example.com/oauth2/token +OIDC_CLIENT_ID=markmonitor-gateway-admin +OIDC_CLIENT_SECRET=*** +``` + +## 3. Run it + +Via `just` (from the repo root): + +```shell +just register-gateway-profiles 1 # dry run — preview only, no gateway calls +just register-gateway-profiles # create/update one profile per product +just register-gateway-profiles 0 1 # apply, then list the resulting profiles +``` + +Or invoke the script directly with the same env vars exported: + +```shell +DRY_RUN=1 ./scripts/register-gateway-profiles.sh +./scripts/register-gateway-profiles.sh +CHECK=1 ./scripts/register-gateway-profiles.sh +``` + +Dry runs are fully offline — no token is fetched and no gateway calls are made — so `DRY_RUN=1` works +even before any auth variables are set. + +## Customizing key algorithms + +By default, profiles allow RSA 2048/3072/4096 and ECDSA P-256/P-384/P-521 (the curves MarkMonitor's +CSR validation accepts). Override with `KEY_ALGS_JSON` if you need a narrower or wider set: + +```shell +KEY_ALGS_JSON='{"rsa": {"bit_lengths": [2048, 4096]}, "ecdsa": {"curves": ["1.2.840.10045.3.1.7"]}}' \ + just register-gateway-profiles +``` + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| `ERROR: required env var 'GATEWAY_HOST' is not set` | `GATEWAY_HOST` (or the auth-method variables for the option you picked) isn't exported and isn't in `.env`. | +| `ERROR: no access_token in response` | `TOKEN_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET` are set but wrong, or the IdP rejected the scope — check the printed response body. | +| `ERROR: unexpected response listing certificate profiles` | `GATEWAY_BASE_PATH` likely doesn't match this gateway instance's actual mount path, or the token/cookie was rejected. | +| TLS errors from `curl` | Set `CURL_INSECURE=0` to enforce certificate verification (default is `1`, i.e. `curl -k`, for lab/self-signed gateways). | diff --git a/docsource/images/gateway_ca_configuration.png b/docsource/images/gateway_ca_configuration.png new file mode 100644 index 0000000..1c705d9 Binary files /dev/null and b/docsource/images/gateway_ca_configuration.png differ diff --git a/docsource/images/gateway_registration_local_file.png b/docsource/images/gateway_registration_local_file.png new file mode 100644 index 0000000..8a22761 Binary files /dev/null and b/docsource/images/gateway_registration_local_file.png differ diff --git a/docsource/images/gateway_registration_store.png b/docsource/images/gateway_registration_store.png new file mode 100644 index 0000000..9971515 Binary files /dev/null and b/docsource/images/gateway_registration_store.png differ diff --git a/docsource/images/gateway_template.png b/docsource/images/gateway_template.png new file mode 100644 index 0000000..028577e Binary files /dev/null and b/docsource/images/gateway_template.png differ diff --git a/docsource/images/template_enrollment_params.png b/docsource/images/template_enrollment_params.png new file mode 100644 index 0000000..ab97218 Binary files /dev/null and b/docsource/images/template_enrollment_params.png differ diff --git a/docsource/overview.md b/docsource/overview.md new file mode 100644 index 0000000..0a2b59d --- /dev/null +++ b/docsource/overview.md @@ -0,0 +1,158 @@ +## Overview + +The MarkMonitor AnyCA Gateway REST plugin extends the certificate lifecycle capabilities of the +MarkMonitor SSL certificate service to Keyfactor Command via the Keyfactor AnyCA Gateway REST. It +implements `IAnyCAPlugin` and is loaded as a DLL extension by the AnyCA Gateway REST host process — +it is not a standalone service. See [configuration.md](configuration.md) for full installation and +configuration details, [architecture.md](architecture.md) for design notes, and +[DEVELOPMENT.md](../DEVELOPMENT.md) for local development and testing. + +The plugin supports the following capabilities: + +* **CA Synchronization** — Downloads all certificate orders visible to the configured MarkMonitor + organization and imports the issued certificates (and their chains) into Keyfactor Command. Orders + that have not yet produced a certificate are mapped to the appropriate pending/failed status + rather than imported as certificates. +* **Certificate Enrollment** — Submits a new MarkMonitor certificate order for each of the SSL + product types MarkMonitor exposes (see the [product IDs](configuration.md#product-ids) table). A + process-local dedup guard prevents Command retries from creating duplicate orders. +* **Renewal / Reissue** — MarkMonitor has no dedicated "renew in place" enrollment endpoint through + this plugin, so a `RenewOrReissue` enrollment places a **new** order and then revokes the prior + certificate once the replacement has been created successfully. +* **Certificate Revocation** — Revokes a previously issued certificate, with a cross-organization + ownership check that refuses to revoke an order belonging to a different organization than the one + the CA connector is configured for. + +MarkMonitor's SSL API is backed by DigiCert (the only certificate `provider` its API currently +supports), so issued certificates chain up to DigiCert roots. + +## Authentication Model + +Unlike some AnyCA plugins, MarkMonitor uses **two** credentials together: + +* An **API key**, sent as the `X-API-KEY` request header on the authentication call. +* A **service-account username and password**, exchanged at `/auth/v1/auth/authenticate` for a + short-lived **bearer token**. All subsequent API calls carry that bearer token. + +The bearer token is cached in memory for the lifetime of the plugin's client and refreshed +automatically shortly before it expires. There is no OAuth client-credentials mode. See +[DEVELOPMENT.md](../DEVELOPMENT.md#request-authentication) for details. + +## MarkMonitor CA Certificates + +Before the gateway can register a CA backed by this plugin, the Keyfactor Command server (and the +AnyCA Gateway REST host) must trust the issuing CA chain. MarkMonitor's default issuing CA +(`provider`) is **DigiCert**, so download the appropriate root and intermediate CA certificates from + and import them into the appropriate +Windows certificate stores on the gateway host (**Trusted Root Certification Authorities** for the +root CA and **Intermediate Certification Authorities** for any subordinates). See +[configuration.md](configuration.md#gateway-registration) for the full Gateway Registration +walkthrough. + +## Troubleshooting + +### Enrollment succeeds but the certificate never arrives immediately + +**Symptom** + +An enrollment returns successfully but the certificate is not delivered inline — Command shows the +request in a pending/external-validation state. A later synchronization picks the certificate up. + +**Root cause** + +This is expected. MarkMonitor SSL orders require Domain Control Validation (DCV) — and, in the +current test configuration, manual email approval — before DigiCert issues the certificate. A newly +placed order comes back with MarkMonitor status `CREATED`, which the plugin maps to Keyfactor's +`EXTERNALVALIDATION` (accepted, still pending) rather than a hard failure. Once the order reaches +`DIGI_ISSUED`, the next incremental CA sync transitions the record to `GENERATED` and the +certificate becomes available in Command. See the status mapping table in +[DEVELOPMENT.md](../DEVELOPMENT.md#order-status-mapping). + +**Mitigation** + +No action needed beyond completing DCV/approval on the MarkMonitor side. The certificate is imported +on the next sync cycle after issuance. + +### An ECC CSR is rejected at enrollment with an "explicit curve parameters" error + +**Symptom** + +Enrolling with an ECC key fails immediately with an error stating the CSR uses explicit curve +parameters instead of a named curve. + +**Root cause** + +MarkMonitor's DigiCert-backed products silently reject an ECC CSR whose public key encodes the curve +with **explicit parameters** (the curve's prime/coefficients/base point spelled out) rather than a +**named-curve OID** (e.g. P-256/`secp256r1`). The order reaches a failed status almost immediately +with no reason surfaced anywhere in MarkMonitor's API, history, or order details. CA/Browser Forum +baseline requirements disallow explicit parameters for publicly trusted certificates. The plugin +validates this at enrollment time and rejects such a CSR with an explicit, actionable error rather +than submitting an order that will fail invisibly. + +**Mitigation** + +Regenerate the CSR with a named curve. `openssl req -in your.csr -noout -text` should show +`ASN1 OID: prime256v1` (named curve) rather than explicit `Prime:` / `A:` / `B:` / `Generator:` +fields. + +### "An identical enrollment … was already submitted" warning + +**Symptom** + +A retried enrollment logs a warning that an identical enrollment was already submitted, and returns +the original result instead of placing a new order. + +**Root cause** + +This is by design. The plugin keeps a short-lived, process-local dedup cache keyed on +organization + product + subject + CSR. When Keyfactor Command retries an `Enroll` call (because the +first response was lost to a timeout or dropped connection, or because the first call is still in +flight), the retry awaits the original in-flight/just-completed result instead of creating a +duplicate MarkMonitor order. The cache window is five minutes and is not durable across gateway +restarts — it guards the narrow retry window only. + +**Mitigation** + +None needed. A genuinely new request (different CSR/subject) is unaffected. A failed attempt is not +cached, so a retry after a real failure gets a fresh attempt. + +### "Refusing to revoke order … it belongs to a different organization" + +**Symptom** + +A revocation fails with an error stating the order belongs to a different organization than the +configured one. + +**Root cause** + +Before revoking, the plugin resolves the CA connector's configured `OrgId` to a GUID, fetches the +order, and compares the order's owning organization against it. MarkMonitor's own +`ignoreOrgCheck=false` default only guards against revoking an order belonging to a different +reseller account entirely — it has no notion of the specific sub-organization this connector is +scoped to. This matters most for `RenewOrReissue`, where the order ID being revoked comes from +Command's certificate store rather than from this org's own enrollment. + +**Mitigation** + +Confirm the CA connector's `OrgId` matches the organization that owns the certificate. If `OrgId` is +left blank, the ownership check is *skipped* (and logged as a warning) for `Ping`/sync, but `Revoke` +refuses to run at all without an `OrgId` configured. + +### Revocation reason code is not reflected in MarkMonitor + +**Symptom** + +A certificate is revoked with a specific RFC 5280 reason code in Command, but MarkMonitor shows no +reason. + +**Root cause** + +MarkMonitor's revoke action (`PATCH /certs/v1/order/{id}/revoke`) has no field for a revocation +reason code — its request schema accepts only cert/`ignoreOrgCheck`/`additionalEmails`. A non-default +reason is logged (so it is visible that the reason was received but could not be forwarded) rather +than silently dropped, but it cannot be sent to MarkMonitor. + +**Mitigation** + +None available at the API level. \ No newline at end of file diff --git a/integration-manifest.json b/integration-manifest.json index 8421822..37abf51 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -1,47 +1,111 @@ { - "$schema": "https://keyfactor.github.io/integration-manifest-schema.json", - "integration_type": "anyca-plugin", - "name": "MarkMonitor AnyCA REST Gateway Plugin", - "status": "pilot", - "support_level": "kf-supported", - "link_github": true, - "update_catalog": true, - "description": "MarkMonitor plugin for the AnyCA REST Gateway framework", - "gateway_framework": "24.2.0", - "release_dir": "markmonitor-cagateway/bin/Release/net6.0", - "about": { - "carest": { - "ca_plugin_config": [ - { - "name": "ClientSecret", - "description": "Client Secret for Generating Bearer Token" - }, - { - "name": "MarkMonitorApiClient", - "description": "MarkMonitor API Client Name" - }, - { - "name": "BaseUrl", - "description": "Base Url for MarkMonitor API such as https://url:8443" - }, - { - "name": "MarkMonitorCaId", - "description": "MarkMonitor Ca Id. Example would be 2. In MarkMonitor Onboard UI, click edit on the Ca and look at the id in the Url." + "$schema": "https://keyfactor.github.io/integration-manifest-schema.json", + "integration_type": "anyca-plugin", + "name": "Markmonitor AnyCA REST Gateway Plugin", + "status": "pilot", + "support_level": "kf-supported", + "link_github": true, + "update_catalog": true, + "description": "Markmonitor plugin for the AnyCA REST Gateway framework", + "gateway_framework": "25.5.0", + "release_dir": "markmonitor-caplugin/bin/Release", + "release_project": "markmonitor-caplugin/markmonitor-caplugin.csproj", + "about": { + "carest": { + "ca_plugin_config": [ + { + "name": "ApiKey", + "description": "The API Key for the MarkMonitor API" + }, + { + "name": "Username", + "description": "Username for the MarkMonitor API service account" + }, + { + "name": "Password", + "description": "Password for the MarkMonitor API service account" + }, + { + "name": "BaseUrl", + "description": "The Base URL for the MarkMonitor API - Usually either https://api.markmonitor.com" + }, + { + "name": "OrgId", + "description": "The name of the MarkMonitor Organization to use for the API calls (ex: MarkMonitor). You can also use the Organization ID in GUID format." + }, + { + "name": "Enabled", + "description": "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available." + }, + { + "name": "TimeoutSeconds", + "description": "The HTTP request timeout, in seconds, for calls to the MarkMonitor API (1-120). Default is 120." + }, + { + "name": "PageSize", + "description": "The number of certificate orders requested per page during synchronization (1-500). Default is 100." + }, + { + "name": "ForceCompleteSync", + "description": "When true, bypasses the skip-unchanged optimization and re-emits every order on every synchronization. Default is false." + }, + { + "name": "PickupRetries", + "description": "How many times to poll a freshly-created order for issuance before returning it in its still-pending state (0-20). 0 disables polling. Default is 5." + }, + { + "name": "PickupDelaySeconds", + "description": "The delay, in seconds, between issuance pickup polls (0-60). Default is 10." + } + ], + "enrollment_config": [ + { + "name": "AdditionalEmails", + "description": "List of 0 or more comma separated email addresses to send the certificate to via email after generation." + }, + { + "name": "MarkmonitorGroup", + "description": "The name or GUID of a Markmonitor group to use for the certificate request." + }, + { + "name": "MarkmonitorContact", + "description": "The name or GUID of a Markmonitor contact to use for the certificate request. Will use default Markmonitor organization contact if not specified." + }, + { + "name": "DCVMethod", + "description": "The method to use for Domain Control Validation (DCV). Valid values are EMAIL, DNS_CNAME_TOKEN, HTTP_TOKEN, DNS_TXT_TOKEN. Default is EMAIL." + }, + { + "name": "comments", + "description": "Comments to attach to the MarkMonitor order. Default is \"Requested via Keyfactor Command\"." + }, + { + "name": "locale", + "description": "Locale to use for the MarkMonitor order. Default is \"en\"." + }, + { + "name": "provider", + "description": "The certificate provider to use for the order. Default is \"DIGICERT\" (currently the only provider MarkMonitor's API supports)." + }, + { + "name": "RenewalWindowDays", + "description": "For a RenewOrReissue enrollment, how many days before its expiration a prior certificate must be within before it is revoked after being replaced. Outside this window, the prior certificate is left unrevoked and the request is treated like a plain new issuance. Default is 90." + } + ], + "product_ids": [ + "SslOvBasic", + "SslEvBasic", + "SslDvGeotrust", + "SslDvThawte", + "SslOvThawteWebserver", + "SslEvThawteWebserver", + "SslOvGeotrustTruebizid", + "SslEvGeotrustTruebizid", + "SslOvSecuresite", + "SslEvSecuresite", + "SslOvSecuresitePro", + "SslEvSecuresitePro" + ] } - ], - "enrollment_config": [ - { - "name": "NumberOfDaysValid", - "description": "OPTIONAL: The number of days of validity to use when requesting certs. If not provided, default is 365." - } - ], - "product_ids": [ - "ca", - "code-signing", - "https", - "tls-client", - "trusted" - ] } - } -} +} \ No newline at end of file diff --git a/justfile b/justfile new file mode 100644 index 0000000..26e47b2 --- /dev/null +++ b/justfile @@ -0,0 +1,137 @@ +# Ad-hoc MarkMonitor API helpers for manual cleanup/inspection during development. +# +# Requires: just, curl, jq +# Requires env vars (loaded automatically from a root .env, see TestConsole/README.md): +# MARKMONITOR_BASE_URL, MARKMONITOR_API_TOKEN, MARKMONITOR_USERNAME, MARKMONITOR_PASSWORD +# +# Order IDs throughout are MarkMonitor's GUID resourceId (the `id` field from the API), NOT the +# short order number from confirmation emails - that number isn't exposed anywhere in this API. +# Use find-order/list-orders to go from a common name to the real GUID first. + +set dotenv-load := true + +# List available commands +default: + @just --list + +# Fetch a fresh bearer token (used internally by the other recipes) +[private] +_auth: + #!/usr/bin/env bash + set -euo pipefail + curl -s -X POST "$MARKMONITOR_BASE_URL/auth/v1/auth/authenticate" \ + -H "X-API-KEY: $MARKMONITOR_API_TOKEN" -H "Content-Type: application/json" \ + -d "{\"username\":\"$MARKMONITOR_USERNAME\",\"password\":\"$MARKMONITOR_PASSWORD\"}" \ + | jq -r .token + +# List every organization visible to this API key +list-orgs: + #!/usr/bin/env bash + set -euo pipefail + BEARER=$(just _auth) + curl -s "$MARKMONITOR_BASE_URL/certs/v1/organization" \ + -H "X-API-KEY: $MARKMONITOR_API_TOKEN" -H "Authorization: Bearer $BEARER" \ + | jq -r '["ORG_ID","NAME"], (.content[] | [.id, .name]) | @tsv' + +# List orders for an organization GUID, optionally comma-separated statuses (e.g. DIGI_PENDING,CREATED) +list-orders org_id statuses="": + #!/usr/bin/env bash + set -euo pipefail + BEARER=$(just _auth) + URL="$MARKMONITOR_BASE_URL/certs/v1/order?organizationId={{org_id}}&size=100" + if [ -n "{{statuses}}" ]; then URL="$URL&statuses={{statuses}}"; fi + curl -s "$URL" -H "X-API-KEY: $MARKMONITOR_API_TOKEN" -H "Authorization: Bearer $BEARER" \ + | jq -r '["ORDER_ID","STATUS","CREATED","COMMON_NAME"], (.content[] | [.id, .status, .dateCreated, .cert.commonName]) | @tsv' + +# Sweep every organization for orders still in a pending/billable state +list-pending: + #!/usr/bin/env bash + set -euo pipefail + BEARER=$(just _auth) + PENDING_STATUSES="CREATED,DIGI_PENDING,DIGI_PROCESSING,DIGI_NEEDS_CSR,DIGI_NEEDS_APPROVAL,DIGI_WAITING_PICKUP,REISSUE_PENDING,DIGI_REISSUE_PENDING,REISSUE_REQUEST_PENDING" + curl -s "$MARKMONITOR_BASE_URL/certs/v1/organization" -H "X-API-KEY: $MARKMONITOR_API_TOKEN" -H "Authorization: Bearer $BEARER" \ + | jq -r '.content[] | [.id, .name] | @tsv' \ + | while IFS=$'\t' read -r org_id org_name; do + curl -s "$MARKMONITOR_BASE_URL/certs/v1/order?organizationId=$org_id&statuses=$PENDING_STATUSES&size=100" \ + -H "X-API-KEY: $MARKMONITOR_API_TOKEN" -H "Authorization: Bearer $BEARER" \ + | jq -r --arg org "$org_name" '.content[] | [$org, .id, .status, .dateCreated, .cert.commonName] | @tsv' + done \ + | (echo -e "ORG\tORDER_ID\tSTATUS\tCREATED\tCOMMON_NAME"; cat) | column -t -s $'\t' + +# Find orders by exact common name +find-order common_name: + #!/usr/bin/env bash + set -euo pipefail + BEARER=$(just _auth) + curl -s "$MARKMONITOR_BASE_URL/certs/v1/order?commonNames={{common_name}}" \ + -H "X-API-KEY: $MARKMONITOR_API_TOKEN" -H "Authorization: Bearer $BEARER" \ + | jq -r '["ORDER_ID","STATUS","CREATED","COMMON_NAME"], (.content[] | [.id, .status, .dateCreated, .cert.commonName]) | @tsv' + +# Get full order detail by GUID +get-order order_id: + #!/usr/bin/env bash + set -euo pipefail + BEARER=$(just _auth) + curl -s "$MARKMONITOR_BASE_URL/certs/v1/order/{{order_id}}" \ + -H "X-API-KEY: $MARKMONITOR_API_TOKEN" -H "Authorization: Bearer $BEARER" | jq . + +# Cancel an order by GUID (valid for orders that haven't issued yet) +cancel order_id: + #!/usr/bin/env bash + set -euo pipefail + BEARER=$(just _auth) + curl -s -X PATCH "$MARKMONITOR_BASE_URL/certs/v1/order/{{order_id}}/cancel" \ + -H "X-API-KEY: $MARKMONITOR_API_TOKEN" -H "Authorization: Bearer $BEARER" -H "Content-Type: application/json" -d '{}' \ + | jq -r '.status // .' + +# Revoke an order by GUID (only valid for already-issued certs) +revoke order_id: + #!/usr/bin/env bash + set -euo pipefail + BEARER=$(just _auth) + curl -s -X PATCH "$MARKMONITOR_BASE_URL/certs/v1/order/{{order_id}}/revoke" \ + -H "X-API-KEY: $MARKMONITOR_API_TOKEN" -H "Authorization: Bearer $BEARER" -H "Content-Type: application/json" -d '{}' \ + | jq -r '.status // .' + +# Find an order by common name and cancel it in one step (fails if there's more than one match) +cancel-by-name common_name: + #!/usr/bin/env bash + set -euo pipefail + BEARER=$(just _auth) + MATCHES=$(curl -s "$MARKMONITOR_BASE_URL/certs/v1/order?commonNames={{common_name}}" \ + -H "X-API-KEY: $MARKMONITOR_API_TOKEN" -H "Authorization: Bearer $BEARER") + COUNT=$(echo "$MATCHES" | jq -r '.content | length') + if [ "$COUNT" -eq 0 ]; then echo "No order found for {{common_name}}"; exit 1; fi + if [ "$COUNT" -gt 1 ]; then + echo "More than one order matches {{common_name}} - use 'just cancel ' directly:" + echo "$MATCHES" | jq -r '.content[] | [.id, .status, .dateCreated] | @tsv' + exit 1 + fi + ORDER_ID=$(echo "$MATCHES" | jq -r '.content[0].id') + echo "Cancelling $ORDER_ID ({{common_name}})..." + curl -s -X PATCH "$MARKMONITOR_BASE_URL/certs/v1/order/$ORDER_ID/cancel" \ + -H "X-API-KEY: $MARKMONITOR_API_TOKEN" -H "Authorization: Bearer $BEARER" -H "Content-Type: application/json" -d '{}' \ + | jq -r '.status // .' + +# Register/update this plugin's AnyCA REST Gateway certificate profiles (one per +# product in integration-manifest.json). Needs GATEWAY_HOST + gateway auth - see +# scripts/lib/gateway-auth.sh. Set dry_run=1 to preview with no gateway calls, or +# check=1 to list resulting profiles after applying. +register-gateway-profiles dry_run="0" check="0": + #!/usr/bin/env bash + set -euo pipefail + DRY_RUN={{dry_run}} CHECK={{check}} "{{justfile_directory()}}/scripts/register-gateway-profiles.sh" + +# Preview the docs/ GitHub Pages site at http://localhost:4000/markmonitor-caplugin/ (needs Docker) +docs-preview: + docker rm -f markmonitor-docs-preview 2>/dev/null || true + docker run -d --name markmonitor-docs-preview \ + -v "{{justfile_directory()}}/docs:/srv/jekyll" \ + -p 4000:4000 \ + jekyll/jekyll:latest \ + bash -c "bundle install && bundle exec jekyll serve --host 0.0.0.0" + @echo "Building... tail with 'docker logs -f markmonitor-docs-preview', then open http://localhost:4000/markmonitor-caplugin/" + +# Stop the docs preview container started by `just docs-preview` +docs-preview-stop: + docker rm -f markmonitor-docs-preview diff --git a/markmonitor-cagateway.sln b/markmonitor-cagateway.sln deleted file mode 100644 index a53e910..0000000 --- a/markmonitor-cagateway.sln +++ /dev/null @@ -1,38 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.31729.503 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "markmonitor-cagateway", "markmonitor-cagateway\markmonitor-cagateway.csproj", "{9D2D6ED9-4626-430C-879D-0FE0FEBED146}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{431498A1-F30A-4307-9FBF-B1D634326444}" - ProjectSection(SolutionItems) = preProject - CHANGELOG.md = CHANGELOG.md - integration-manifest.json = integration-manifest.json - readme_source.md = readme_source.md - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestConsole", "TestConsole\TestConsole.csproj", "{BE76E7C9-7DDE-49CD-8428-8256739BAD93}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Release|Any CPU.Build.0 = Release|Any CPU - {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {5D2E21F6-120F-4B71-A596-991879B03943} - EndGlobalSection -EndGlobal diff --git a/markmonitor-cagateway/Client/MarkMonitorClient.cs b/markmonitor-cagateway/Client/MarkMonitorClient.cs deleted file mode 100644 index 3905b2e..0000000 --- a/markmonitor-cagateway/Client/MarkMonitorClient.cs +++ /dev/null @@ -1,849 +0,0 @@ -using System.Collections.Concurrent; -using System.Net.Http.Headers; -using System.Text; -using Keyfactor.AnyGateway.Extensions; -using Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; -using Keyfactor.Logging; -using Keyfactor.PKI.Enums.EJBCA; -using Keyfactor.PKI.PEM; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Org.BouncyCastle.Pkcs; -using Org.BouncyCastle.Tls; - -namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; - -public class MarkMonitorClient -{ - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - private string _apiKey; - private string _bearerToken; - private string _password; - private string _username; - - public MarkMonitorClient(string baseUrl, string apiKey, string username, string password, bool validateSsl = true) - { - BaseUrl = baseUrl; - _logger = LogHandler.GetClassLogger(GetType()); - _apiKey = apiKey; - _username = username; - _password = password; - - var handler = new HttpClientHandler { UseCookies = false }; - - if (!validateSsl) - { - _logger.LogWarning("SSL certificate validation is disabled for {BaseUrl}", baseUrl); - handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true; - } - - - _httpClient = new HttpClient(handler); - // _ = AuthenticateAsync(); - } - - public string BaseUrl { get; } - - private bool ValidateConfiguration() - { - if (string.IsNullOrEmpty(_apiKey)) - throw new ConfigurationValidationException("API Key is required."); - - if (string.IsNullOrEmpty(_username)) - throw new ConfigurationValidationException("Username is required."); - - if (string.IsNullOrEmpty(_password)) - throw new ConfigurationValidationException("Password is required."); - - return true; - } - - public async Task AuthenticateAsync(string apiKey = null, string username = null, string password = null) - { - _logger.MethodEntry(); - if (!string.IsNullOrEmpty(apiKey)) - { - _logger.LogDebug("Setting API Key"); - _apiKey = apiKey; - } - - if (!string.IsNullOrEmpty(username)) - { - _logger.LogDebug("Setting username"); - _username = username; - } - - if (!string.IsNullOrEmpty(password)) - { - _logger.LogDebug("Setting password"); - _password = password; - } - - _logger.LogDebug("Calling ValidateConfiguration"); - var isValid = ValidateConfiguration(); - if (!isValid) throw new ConfigurationValidationException("Invalid configuration"); - - _logger.LogDebug("Setting \"X-API-KEY\" header"); - _httpClient.DefaultRequestHeaders.Add("X-API-KEY", _apiKey); - var requestBody = new TokenRequest - { - Username = _username, - Password = _password - }; - - var requestUrl = $"{BaseUrl}/auth/v1/auth/authenticate"; - _logger.LogInformation("Authenticating with MarkMonitor API at {RequestUrl}", requestUrl); - - _logger.LogDebug("Sending authentication request"); - var response = await _httpClient.PostAsync(requestUrl, - new StringContent(JsonConvert.SerializeObject(requestBody), Encoding.UTF8, "application/json")); - - _logger.LogDebug("Reading authentication response"); - response.EnsureSuccessStatusCode(); - var content = await response.Content.ReadAsStringAsync(); - - _logger.LogTrace("Authentication response code: {ResponseCode}", response.StatusCode); - if (response.IsSuccessStatusCode) - { - _logger.LogDebug("Deserializing token response"); - var tokenResponse = JsonConvert.DeserializeObject(content); - _bearerToken = tokenResponse.BearerToken; - _logger.LogDebug("Bearer token received and valid for {TokenExpiration} seconds", - tokenResponse.ExpiresIn); - _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _bearerToken); - } - else - { - var errMsg = BuildErrorString(content); - _logger.LogError("Authentication failed: {EMessage}", errMsg); - throw new Exception(errMsg); - } - - _logger.LogInformation("Authentication successful"); - } - - public async Task GetCertificateInventoryAsync(string caId, string sort, int limit, - BlockingCollection certificatesBuffer, CancellationToken cancelToken) - { - _logger.MethodEntry(); - try - { - EnsureAuthenticated(); - _logger.LogInformation("Retrieving certificate inventory from MarkMonitor"); - var certificateOrders = - await ListCertificateOrdersAsync(0, caId, sort, limit); //todo: providerId support??? - _logger.LogDebug("Retrieved '{CertificateCount}' certificate orders", certificateOrders.Count); - - var numberOfCertificates = 0; - foreach (var certificateDetail in certificateOrders) - { - _logger.LogInformation("Adding certificate {CertificateId} to buffer", certificateDetail.Id); - var certStatus = MarkMonitorCertificateStatusToCAStatus(certificateDetail); - _logger.LogTrace("Certificate {CertificateId} status: {CertificateStatus}", certificateDetail.Id, - certStatus); - - _logger.LogDebug("Converting certificate {CertificateId} revocation status {Status}", - certificateDetail.Id, certificateDetail.Cert.RevokeStatus); - DateTime? revocationDate = null; - if (certificateDetail.Cert.RevokeStatus == "REVOKED") - { - _logger.LogDebug("Certificate {CertificateId} is revoked", certificateDetail.Id); - revocationDate = Convert.ToDateTime(certificateDetail.Cert.DateValidUntil); - } - - certificatesBuffer.Add( - new AnyCAPluginCertificate - { - CARequestID = certificateDetail.Id, - Certificate = certificateDetail.Cert.EndEntityCert, - Status = certStatus, - ProductID = certificateDetail.CertType, - RevocationDate = revocationDate - }, cancelToken); - numberOfCertificates++; - _logger.LogTrace("Total certificates added to buffer: {NumberOfCertificates}", numberOfCertificates); - } - - _logger.LogInformation("Retrieved {NumberOfCertificates} certificates", numberOfCertificates); - return numberOfCertificates; - } - catch (OperationCanceledException) - { - _logger.LogInformation("Certificate inventory retrieval cancelled"); - throw; // Rethrow the cancellation exception to ensure it's propagated - } - catch (Exception e) - { - _logger.LogError("An error has occurred: {EMessage}", e.Message); - return 0; - } - finally - { - certificatesBuffer.CompleteAdding(); // Ensure buffer is completed even on cancellation - _logger.MethodExit(); - } - } - - private List BuildQueryString(int providerId, string orgId, string sort, int limit, int page) - { - _logger.MethodEntry(); - var query = new List(); - if (page > 0) query.Add($"page={page}"); - if (limit > 0) query.Add($"size={limit}"); - if (!string.IsNullOrEmpty(sort)) query.Add($"sort={sort}"); - if (providerId > 0) query.Add($"providerId={providerId}"); - if (!string.IsNullOrEmpty(orgId)) query.Add($"organizationId={orgId}"); - _logger.MethodExit(); - return query; - } - - private List BuildListOrgsQueryString(string name = "", string sort = "", int limit = 0, int page = 0) - { - _logger.MethodEntry(); - var query = new List(); - if (page > 0) query.Add($"page={page}"); - if (limit > 0) query.Add($"size={limit}"); - if (!string.IsNullOrEmpty(sort)) query.Add($"sort={sort}"); - if (!string.IsNullOrEmpty(name)) query.Add($"name={name}"); - - _logger.LogTrace("Query string: {Query}", query); - _logger.MethodExit(); - return query; - } - - public async Task> ListCertificateOrdersAsync(int providerId, string orgId, string sort, - int limit) - { - _logger.MethodEntry(); - EnsureAuthenticated(); - var output = new List(); - try - { - _logger.LogInformation("Retrieving certificate orders from MarkMonitor"); - var currentPage = 0; - var allPagesDownloaded = false; - - do - { - var nextUrl = - $"{BaseUrl}/certs/v1/order"; - _logger.LogTrace("Base URL: {BaseUrl}", nextUrl); - - _logger.LogDebug("Building query string"); - var query = BuildQueryString(providerId, orgId, sort, limit, currentPage); - if (query.Count > 0) nextUrl += "?" + string.Join("&", query); - - _logger.LogTrace("Getting page \'{CurrentPage}\' of \'{Limit}\')", currentPage, limit); - - _logger.LogDebug("Getting certificate orders from MarkMonitor {NextUrl}", nextUrl); - var response = await _httpClient.GetAsync(nextUrl); // Pass the token here - - _logger.LogDebug("Reading response content"); - var content = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) throw new Exception(BuildErrorString(content)); - - _logger.LogDebug("Deserializing response content to MarkMonitorListOrdersResponse"); - var certificateListResponse = JsonConvert.DeserializeObject(content); - output.AddRange(certificateListResponse.Content); - currentPage++; - allPagesDownloaded = currentPage >= certificateListResponse.MarkMonitorPage.TotalPages; - } while (!allPagesDownloaded); - - return output; - } - catch (OperationCanceledException) - { - _logger.LogInformation("Certificate inventory retrieval cancelled"); - throw; // Rethrow the cancellation exception to ensure it's propagated - } - catch (Exception e) - { - _logger.LogError("An error has occurred: {EMessage}", e.Message); - return null; - } - finally - { - _logger.MethodExit(); - } - } - - public async Task> ListOrganizationsAsync(int page = 0, int limit = 0, - string name = "") - { - _logger.MethodEntry(); - EnsureAuthenticated(); - var output = new List(); - try - { - _logger.LogInformation("Retrieving organizations from MarkMonitor"); - var currentPage = 0; - var allPagesDownloaded = false; - - do - { - var nextUrl = - $"{BaseUrl}/certs/v1/organization"; - - _logger.LogTrace("Base URL: {BaseUrl}", nextUrl); - - _logger.LogDebug("Building query string"); - var query = BuildListOrgsQueryString(name, "", limit, currentPage); - if (query.Count > 0) nextUrl += "?" + string.Join("&", query); - - _logger.LogTrace("Getting page \'{CurrentPage}\' of \'{Limit}\')", currentPage, limit); - - _logger.LogDebug("Getting organizations from MarkMonitor {NextUrl}", nextUrl); - var response = await _httpClient.GetAsync(nextUrl); // Pass the token here - - _logger.LogDebug("Reading response content"); - var content = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) throw new Exception(BuildErrorString(content)); - - _logger.LogDebug("Deserializing response content to MarkMonitorListOrgResponse"); - var orgListResponse = JsonConvert.DeserializeObject(content); - - _logger.LogDebug("Adding organizations to output"); - output.AddRange(orgListResponse.Content); - currentPage++; - _logger.LogTrace("Total organizations added to output: {OutputCount}", output.Count); - allPagesDownloaded = currentPage >= orgListResponse.MarkMonitorPage.TotalPages; - } while (!allPagesDownloaded); - - return output; - } - catch (OperationCanceledException) - { - _logger.LogInformation("Organization retrieval cancelled"); - throw; // Rethrow the cancellation exception to ensure it's propagated - } - catch (Exception e) - { - _logger.LogError("An error has occurred: {EMessage}", e.Message); - return null; - } - finally - { - _logger.MethodExit(); - } - } - - public async Task GetSingleOrderAsync(string orderId) - { - try - { - EnsureAuthenticated(); - - _httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", _bearerToken); - _httpClient.DefaultRequestHeaders.Accept.Add( - new MediaTypeWithQualityHeaderValue("application/json")); - - var response = await _httpClient.GetAsync($"{BaseUrl}/certs/v1/order/{orderId}"); - var content = await response.Content.ReadAsStringAsync(); - OrderContent order; - if (response.IsSuccessStatusCode) - order = JsonConvert.DeserializeObject(content); - else - throw new Exception(BuildErrorString(content)); - - return new AnyCAPluginCertificate - { - CARequestID = order.Id, - Certificate = order.Cert.EndEntityCert, - Status = MarkMonitorCertificateStatusToCAStatus(order), - ProductID = order.CertType, - RevocationDate = Convert.ToDateTime(order.Cert.DaysRemaining) - }; - } - catch (Exception e) - { - _logger.LogError("An error has occurred: {EMessage}", e.Message); - return null; - } - } - - private string getCsrAlgorithm(Pkcs10CertificationRequest csr) - { - var signatureAlgorithm = csr.SignatureAlgorithm.Algorithm.Id; - var requestAlgorithm = signatureAlgorithm switch - { - //check if algorithm is RSA or ECC - "1.2.840.113549.1.1.11" => AlgorithmTypes.Rsa.GetDescription(), - "1.2.840.10045.4.3.1" or "1.2.840.10045.4.3.2" or "1.2.840.10045.4.3.3" or "1.2.840.10045.4.3.4" - or "1.2.840.10045.2.1" => AlgorithmTypes.Ecc.GetDescription(), - // "2.16.840.1.101.3.4.3.1" or "2.16.840.1.101.3.4.3.2" or "2.16.840.1.101.3.4.3.3" - // or "2.16.840.1.101.3.4.3.4" => AlgorithmTypes.Dsa.GetDescription(), //DSA not supported - _ => throw new Exception($"Invalid CSR signature algorithm {signatureAlgorithm}") - }; - return requestAlgorithm; - } - - public async Task EnrollCertificateAsync(string csr, string subject, - Dictionary san, string orderType, Dictionary productParams, - MarkMonitorConfig config) - { - _logger.MethodEntry(); - try - { - EnsureAuthenticated(); - - var additionalEmails = - productParams.GetValueOrDefault("additionalEmails"); - var additionalEmailsList = new List(); - if (!string.IsNullOrEmpty(additionalEmails)) - { - _logger.LogTrace("Additional emails provided: {AdditionalEmails}", additionalEmails); - additionalEmails = additionalEmails.Replace(" ", ","); - additionalEmailsList = additionalEmails.Split(',').ToList(); - } - - var orgIds = await ListOrganizationsAsync(0, 1, config.OrgName); - _logger.LogTrace("Organizations found: {@OrgIds}", orgIds); - var orgId = orgIds.FirstOrDefault()?.Id; - _logger.LogTrace("Organization ID: {OrgId}", orgId); - if (string.IsNullOrEmpty(orgId)) - { - _logger.LogError("Organization ID not found for {OrgName}", config.OrgName); - throw new InvalidDataException($"Organization ID '{config.OrgName}' not found"); - } - - var orgIdGuid = Guid.Parse(orgId); - - var comments = productParams.GetValueOrDefault("comments", "Requested via Keyfactor Command"); - _logger.LogTrace("Comments: {Comments}", comments); - var locale = productParams.GetValueOrDefault("locale", "en"); - _logger.LogTrace("Locale: {Locale}", locale); - var provider = productParams.GetValueOrDefault("provider", "DIGICERT"); - _logger.LogTrace("Provider: {Provider}", provider); - - // Lookup order type in CertOrderTypes enum - _logger.LogDebug("Looking up order type {OrderType} in CertOrderTypes enum", orderType); - var certOrderType = Enum.Parse(orderType); - - _logger.LogDebug("Deserializing CSR"); - _logger.LogTrace("CSR: {Csr}", csr); - var csrObject = new Pkcs10CertificationRequest(GetCsrBytes(csr)); - var csrInfo = csrObject.GetCertificationRequestInfo(); - - _logger.LogDebug("Determining CSR algorithm"); - var requestAlgorithm = getCsrAlgorithm(csrObject); - _logger.LogTrace("CSR algorithm: {RequestAlgorithm}", requestAlgorithm); - - _logger.LogDebug("Converting CSR to PEM"); - var csrPem = PemUtilities.DERToPEM(csrObject.GetEncoded(), PemUtilities.PemObjectType.CertRequest); - _logger.LogTrace("CSR PEM: {CsrPem}", csrPem); - - _logger.LogDebug("Constructing certificate order object"); - var certOrder = new MarkMonitorCreateOrderRequest - { - AdditionalEmails = additionalEmailsList, - SkipPrice = true, - OrganizationId = orgIdGuid, - // GroupId = null, - // Contacts = orderContacts, - Comments = comments, - CertType = certOrderType.GetDescription(), - Locale = locale, - Provider = provider, - Cert = new MarkMonitorOrderRequestCert - { - CommonName = cleanSubject(subject), - Csr = csrPem.Replace("\r", ""), - DcvMethod = "EMAIL", - DcvEmails = new List(), - AlgorithmHash = requestAlgorithm - } - }; - - - _logger.LogDebug("Calling CreateCertificateOrder"); - logCreateOrderRequest(certOrder); - var order = await CreateCertificateOrder(certOrder); - - if (order == null) throw new Exception($"Failed to enroll certificate `{subject}` with MarkMonitor"); - - _logger.LogInformation("Certificate enrolled successfully"); - return new EnrollmentResult - { - CARequestID = order.Id, - Certificate = order.Cert?.EndEntityCert, - Status = MarkMonitorCertificateStatusToCAStatus(order), - StatusMessage = "MarkMonitor order status: " + order.Status - }; - } - catch (Exception e) - { - _logger.LogError("An error has occurred: {EMessage}", e.Message); - return null; - } - finally - { - _logger.MethodExit(); - } - } - - private string cleanSubject(string subject) - { - _logger.MethodEntry(); - try - { - // Search for the CN field in the Subject - var cnPrefix = "CN="; - var cnIndex = subject.IndexOf(cnPrefix, StringComparison.Ordinal); - if (cnIndex < 0) return subject; - var cnStart = cnIndex + cnPrefix.Length; - var cnEnd = subject.IndexOf(",", cnStart, StringComparison.Ordinal); - if (cnEnd < 0) cnEnd = subject.Length; - return subject.Substring(cnStart, cnEnd - cnStart); - - } - finally - { - _logger.MethodExit(); - } - } - - private void logCreateOrderRequest(MarkMonitorCreateOrderRequest request) - { - _logger.MethodEntry(); - _logger.LogTrace("CommonName: {CommonName}", request.Cert.CommonName); - _logger.LogTrace("OrganizationId: {OrganizationId}", request.OrganizationId); - _logger.LogTrace("CertType: {CertType}", request.CertType); - _logger.LogTrace("Locale: {Locale}", request.Locale); - _logger.LogTrace("Provider: {Provider}", request.Provider); - _logger.LogTrace("Comments: {Comments}", request.Comments); - _logger.LogTrace("AdditionalEmails: {AdditionalEmails}", request.AdditionalEmails); - _logger.LogTrace("SkipPrice: {SkipPrice}", request.SkipPrice); - _logger.LogTrace("CSR: {Csr}", request.Cert.Csr); - _logger.LogTrace("Cert: {@Cert}", request.Cert); - _logger.MethodExit(); - } - - public async Task CreateCertificateOrder(MarkMonitorCreateOrderRequest request) - { - _logger.MethodEntry(); - try - { - EnsureAuthenticated(); - logCreateOrderRequest(request); - - var url = $"{BaseUrl}/certs/v1/order"; - _logger.LogDebug("Creating certificate order at {Url}", url); - var jsonPayload = new StringContent( - JsonConvert.SerializeObject(request), - Encoding.UTF8, "application/json" - ); - _logger.LogTrace("Create order payload: {@Payload}", jsonPayload); - _logger.LogTrace("Request JSON: {Json}", request.JSONString()); - Console.WriteLine(jsonPayload.ReadAsStringAsync()); - var response = await _httpClient.PostAsync(url, jsonPayload); - _logger.LogTrace("Response: {Response}", response); - - _logger.LogDebug("Reading response content"); - var content = await response.Content.ReadAsStringAsync(); - _logger.LogTrace("Response content: {Content}", content); - if (response.IsSuccessStatusCode) - { - var order = JsonConvert.DeserializeObject(content); - _logger.LogInformation("Certificate order {OrderId} created", order.Id); - return order; - } - - var errMsg = BuildErrorString(content); - _logger.LogError("An error has occurred while attempting to create order: {EMessage}", errMsg); - throw new Exception(errMsg); - } - catch (Exception e) - { - _logger.LogError("An error has occurred while attempting to create order: {EMessage}", e.Message); - throw; - } - finally - { - _logger.MethodExit(); - } - } - - public async Task CancelCertificateAsync(string orderId) - { - _logger.MethodEntry(); - try - { - _logger.LogInformation("Revoking certificate {CertificateId}", orderId); - EnsureAuthenticated(); - - var url = $"{BaseUrl}/certs/v1/order/{orderId}/cancel"; - _logger.LogDebug("Revoking certificate at {Url}", url); - var payload = new StringContent("", Encoding.UTF8, "application/json"); - var response = await _httpClient.PatchAsync(url, payload); - - _logger.LogDebug("Reading response content"); - var content = await response.Content.ReadAsStringAsync(); - if (response.IsSuccessStatusCode) - { - _logger.LogInformation("Certificate {CertificateId} has been revoked", orderId); - return true; - } - - var errMsg = BuildErrorString(content); - _logger.LogError("An error has occurred while attempting to cancel order {CertificateId}: {EMessage}", - orderId, errMsg); - throw new Exception(errMsg); - } - catch (Exception e) - { - _logger.LogError("An error has occurred while attempting to cancel {CertificateId}: {EMessage}", orderId, - e.Message); - throw; - } - } - - public async Task ReissueCertificateAsync(string orderId, MarkMonitorReissueRequest payload) - { - _logger.MethodEntry(); - try - { - _logger.LogInformation("Revoking certificate {CertificateId}", orderId); - EnsureAuthenticated(); - - var url = $"{BaseUrl}/certs/v1/order/{orderId}/reissue"; - _logger.LogDebug("Reissuing certificate at {Url}", url); - //convert payload to json - var jsonPayload = new StringContent( - JsonConvert.SerializeObject(payload), - Encoding.UTF8, "application/json" - ); - _logger.LogTrace("Reissue payload: {@Payload}", jsonPayload); - var response = await _httpClient.PatchAsync(url, jsonPayload); - - _logger.LogDebug("Reading response content"); - var content = await response.Content.ReadAsStringAsync(); - if (response.IsSuccessStatusCode) - { - _logger.LogInformation("Certificate {CertificateId} has been reissued", orderId); - return true; - } - - var errMsg = BuildErrorString(content); - _logger.LogError("An error has occurred while attempting to reissue {CertificateId}: {EMessage}", orderId, - errMsg); - throw new Exception(errMsg); - } - catch (Exception e) - { - _logger.LogError("An error has occurred while attempting to reissue {CertificateId}: {EMessage}", orderId, - e.Message); - throw; - } - } - - public async Task RevokeCertificateAsync(string orderId, string orgName = null, uint reason = 0) - { - _logger.MethodEntry(); - try - { - _logger.LogInformation("Revoking certificate associated with order {OrderId}", orderId); - EnsureAuthenticated(); - - var url = $"{BaseUrl}/certs/v1/order/{orderId}/revoke"; - _logger.LogDebug("Revoking certificate at {Url}", url); - var payload = new StringContent("", Encoding.UTF8, "application/json"); - var response = await _httpClient.PatchAsync(url, payload); - - _logger.LogDebug("Reading response content"); - var content = await response.Content.ReadAsStringAsync(); - if (response.IsSuccessStatusCode) - { - _logger.LogInformation("Certificate {CertificateId} has been revoked", orderId); - return true; - } - - var errMsg = BuildErrorString(content); - _logger.LogError("An error has occurred while attempting to revoke {CertificateId}: {EMessage}", orderId, - errMsg); - throw new Exception(errMsg); - } - catch (Exception e) - { - _logger.LogError("An error has occurred while attempting to revoke {CertificateId}: {EMessage}", orderId, - e.Message); - throw; - } - finally - { - _logger.MethodExit(); - } - } - - private void EnsureAuthenticated() - { - if (string.IsNullOrEmpty(_bearerToken)) AuthenticateAsync().RunSynchronously(); - } - - private static string BuildErrorString(string jsonString) - { - var json = JObject.Parse(jsonString); - var errorMessages = new List(); - - if (json["validation_messages"] != null) - foreach (var validationMessage in json["validation_messages"]) - { - var field = validationMessage.Path; - var fieldErrors = (JObject)validationMessage.First; - - foreach (var error in fieldErrors) - { - var errorMessage = error.Value.ToString(); - errorMessages.Add($"{field}: {errorMessage}"); - - if (error.Key == "options") - { - var options = string.Join(", ", error.Value.ToObject>()); - errorMessages.Add($"{field} options: {options}"); - } - } - } - else if (json["detail"] != null) - errorMessages.Add(json["detail"].ToString()); - else - return "No validation errors found."; - - return string.Join(Environment.NewLine, errorMessages); - } - - private byte[] GetCsrBytes(string csr) - { - _logger.MethodEntry(); - try - { - _logger.LogDebug("Attempting to decode CSR string"); - // Try to decode the string from Base64 - return Convert.FromBase64String(csr); - } - catch (FormatException) - { - _logger.LogDebug("Decoding failed, assuming PEM format"); - // If decoding fails, assume the string is in PEM format - var pem = csr.Replace("-----BEGIN CERTIFICATE REQUEST-----", "") - .Replace("-----END CERTIFICATE REQUEST-----", "") - .Replace("\n", "") - .Replace("\r", "") - .Trim(); - return Convert.FromBase64String(pem); - } - finally - { - _logger.MethodExit(); - } - } - - private int MarkMonitorCertificateStatusToCAStatus(OrderContent order) - { - _logger.MethodEntry(); - if (order == null || string.IsNullOrEmpty(order.Status)) - { - _logger.LogError("MarkMonitor order is null or status is empty"); - return (int)EndEntityStatus.FAILED; - } - - - _logger.LogDebug("MarkMonitor order {OrderId} status: {OrderStatus}", order.Id, order.Status); - if ( - order.Status.Equals(OrderStatus.DigiPending.GetDescription(), StringComparison.OrdinalIgnoreCase) || - order.Status.Equals(OrderStatus.DigiProcessing.GetDescription(), StringComparison.OrdinalIgnoreCase) || - order.Status.Equals(OrderStatus.DigiReissuePending.GetDescription(), StringComparison.OrdinalIgnoreCase) || - order.Status.Equals(OrderStatus.DigiWaitingPickup.GetDescription(), StringComparison.OrdinalIgnoreCase) || - order.Status.Equals(OrderStatus.ReissuePending.GetDescription(), StringComparison.OrdinalIgnoreCase) || - order.Status.Equals(OrderStatus.ReissueRequestPending.GetDescription(), StringComparison.OrdinalIgnoreCase) - ) - { - _logger.LogDebug("MarkMonitor order {OrderId} status resolved to 'IN PROCESS'", order.Id); - return (int)EndEntityStatus.INPROCESS; - } - - if ( - order.Status.Equals(OrderStatus.DigiRevoked.GetDescription(), StringComparison.OrdinalIgnoreCase) - ) - { - _logger.LogDebug("MarkMonitor order {OrderId} status resolved to 'REVOKED'", order.Id); - _logger.MethodExit(); - return (int)EndEntityStatus.REVOKED; - } - - - if (order.Status.Equals(OrderStatus.DigiIssued.GetDescription(), StringComparison.OrdinalIgnoreCase)) - { - _logger.LogDebug("MarkMonitor order {OrderId} status resolved to 'GENERATED'", order.Id); - _logger.MethodExit(); - return (int)EndEntityStatus.GENERATED; - } - - - if ( - order.Status.Equals(OrderStatus.DigiFailed.GetDescription(), StringComparison.OrdinalIgnoreCase) || - order.Status.Equals(OrderStatus.DigiReissueFailed.GetDescription(), StringComparison.OrdinalIgnoreCase) - ) - { - _logger.LogDebug("MarkMonitor order {OrderId} status resolved to 'FAILED'", order.Id); - _logger.MethodExit(); - return (int)EndEntityStatus.FAILED; - } - - - if ( - order.Status.Equals(OrderStatus.DigiCanceled.GetDescription(), StringComparison.OrdinalIgnoreCase) || - order.Status.Equals(OrderStatus.DigiRejected.GetDescription(), StringComparison.OrdinalIgnoreCase) || - order.Status.Equals(OrderStatus.DigiExpired.GetDescription(), StringComparison.OrdinalIgnoreCase) || - order.Status.Equals(OrderStatus.DigiNeedsCsr.GetDescription(), StringComparison.OrdinalIgnoreCase) - ) - { - _logger.LogDebug("MarkMonitor order {OrderId} status resolved to 'CANCELLED'", order.Id); - _logger.MethodExit(); - return (int)EndEntityStatus.CANCELLED; - } - - - if ( - order.Status.Equals(OrderStatus.Created.GetDescription(), StringComparison.OrdinalIgnoreCase) - ) - { - _logger.LogDebug("MarkMonitor order {OrderId} status resolved to 'INITIALIZED'", order.Id); - _logger.MethodExit(); - return (int)EndEntityStatus.INITIALIZED; - } - - _logger.LogError("MarkMonitor order {OrderId} status could not be resolved defaulting to 'FAILED'", order.Id); - _logger.MethodExit(); - return (int)EndEntityStatus.FAILED; - } - // - // private static int MarkMonitorCertificateStatusToCAStatus(Certificate cert) - // { - // if (cert.RevokedAt != null) return (int)EndEntityStatus.REVOKED; - // - // if (cert.HasCertificate && cert.IsValid) return (int)EndEntityStatus.GENERATED; - // - // return (int)EndEntityStatus.FAILED; - // } -} - -public class ConfigurationValidationException : Exception -{ - public ConfigurationValidationException() - { - } - - public ConfigurationValidationException(string message) - : base(message) - { - } - - public ConfigurationValidationException(string message, Exception innerException) - : base(message, innerException) - { - } -} \ No newline at end of file diff --git a/markmonitor-cagateway/Constants.cs b/markmonitor-cagateway/Constants.cs deleted file mode 100644 index b6f95bd..0000000 --- a/markmonitor-cagateway/Constants.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Keyfactor.Extensions.CAPlugin.MarkMonitor; - -public class Constants -{ - //Define any constants needed here (mostly field names for config parameters) -} \ No newline at end of file diff --git a/markmonitor-cagateway/MarkMonitorCAConnector.cs b/markmonitor-cagateway/MarkMonitorCAConnector.cs deleted file mode 100644 index 01bee0b..0000000 --- a/markmonitor-cagateway/MarkMonitorCAConnector.cs +++ /dev/null @@ -1,287 +0,0 @@ -using System.Collections.Concurrent; -using Keyfactor.AnyGateway.Extensions; -using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; -using Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; -using Keyfactor.Logging; -using Keyfactor.PKI.Enums.EJBCA; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using MarkMonitorConstants = Keyfactor.Extensions.CAPlugin.MarkMonitor.MarkMonitorCAPluginConfig; - -namespace Keyfactor.Extensions.CAPlugin.MarkMonitor; - -public class MarkMonitorCAPlugin : IAnyCAPlugin -{ - private readonly ILogger _logger = LogHandler.GetClassLogger(); - private ICertificateDataReader _certificateDataReader; - private MarkMonitorConfig _config; - private MarkMonitorClient Client; - private bool _markMonitorClientWasInjected = false; - - - private Dictionary DCVTokens { get; } = new(); - - public MarkMonitorCAPlugin() - { - // Explicit default constructor - } - - public MarkMonitorCAPlugin(MarkMonitorClient client) - { - Client = client; - _markMonitorClientWasInjected = true; - } - - public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) - { - _logger.MethodEntry(); - _certificateDataReader = certificateDataReader; - var rawConfig = JsonConvert.SerializeObject(configProvider.CAConnectionData); - _config = JsonConvert.DeserializeObject(rawConfig); - _logger.LogTrace("MarkMonitorCAPlugin initialized with config: {Config}", rawConfig); - _logger.MethodExit(); - } - - - private void logConfig() - { - _logger.MethodEntry(); - _logger.LogInformation("MarkMonitorCAPlugin config baseUrl: {Config}", _config.BaseUrl); - _logger.LogInformation("MarkMonitorCAPlugin config apiKey: {Config}", _config.ApiKey); - _logger.LogInformation("MarkMonitorCAPlugin config apiUsername: {Config}", _config.ApiUsername); - _logger.LogInformation("MarkMonitorCAPlugin config apiPassword: {Config}", _config.ApiPassword); - _logger.LogInformation("MarkMonitorCAPlugin config orgName: {Config}", _config.OrgName); - _logger.MethodExit(); - } - - public async Task GetSingleRecord(string caRequestId) - { - _logger.MethodEntry(); - var client = await CreateAndAuthenticateClientAsync(); - _logger.LogInformation("Getting order details for CARequestID: {CARequestID}", caRequestId); - var order = await client.GetSingleOrderAsync(caRequestId); - _logger.LogInformation("Order details retrieved for CARequestID: {CARequestID}", caRequestId); - _logger.MethodExit(); - return order; - } - - public async Task Synchronize(BlockingCollection blockingBuffer, DateTime? lastSync, - bool fullSync, CancellationToken cancelToken) - { - _logger.MethodEntry(); - - try - { - _logger.LogInformation(fullSync - ? "Performing a full CA synchronization" - : "Performing a partial CA synchronization"); - - logConfig(); - - _logger.LogDebug("Calling CreateAndAuthenticateClientAsync"); - var client = await CreateAndAuthenticateClientAsync(); - _logger.LogDebug("CreateAndAuthenticateClientAsync completed"); - - _logger.LogInformation("Attempting to synchronize certificates with MarkMonitor API"); - var certificates = await client.GetCertificateInventoryAsync("", "", 100, blockingBuffer, cancelToken); - _logger.LogDebug("Synchronized {Certificates} certificates", certificates); - - // Check for cancellation after operation - // cancelToken.ThrowIfCancellationRequested(); - } - catch (OperationCanceledException) - { - _logger.LogInformation("Synchronization canceled"); - throw; // Rethrow the cancellation exception to ensure it's propagated - } - catch (Exception ex) - { - _logger.LogError("An error occurred during synchronization: {ExMessage}", ex.Message); - throw; - } - finally - { - _logger.MethodExit(); - } - } - - public async Task Revoke(string orderId, string hexSerialNumber, uint revocationReason) - { - _logger.MethodEntry(); - try - { - _logger.LogInformation( - "Revoking certificate with CARequestID: {CaRequestId}, SerialNumber: {HexSerialNumber}, Reason: {RevocationReason}", - orderId, hexSerialNumber, revocationReason); - - var client = await CreateAndAuthenticateClientAsync(); - - _logger.LogInformation("Attempting to revoke certificate with CARequestID: {CaRequestId}", orderId); - - var revokeResult = await client.RevokeCertificateAsync(orderId, _config.OrgName, revocationReason); - - if (revokeResult) return (int)EndEntityStatus.REVOKED; - - throw new Exception("Unable to revoke certificate associated with order ID: " + orderId); - } - catch (Exception e) - { - throw new Exception($"Revoke Failed with message {e?.Message}"); - } - finally - { - _logger.MethodExit(); - } - } - - public async Task Enroll(string csr, string subject, Dictionary san, - EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) - { - _logger.MethodEntry(); - try - { - _logger.LogInformation("Enrolling certificate `{Subject}` with MarkMonitor", subject); - - var client = await CreateAndAuthenticateClientAsync(); - - _logger.LogInformation("Performing an Enrollment"); - - var enrollResult = await client.EnrollCertificateAsync(csr, subject, san, productInfo.ProductID, - productInfo.ProductParameters, _config); - - return enrollResult; - } - finally - { - _logger.MethodExit(); - } - } - - public async Task Ping() - { - _logger.MethodEntry(); - try - { - _logger.LogInformation("Attempting to authenticate with MarkMonitor API"); - var client = await CreateAndAuthenticateClientAsync(); - - if (client == null) throw new Exception("Error attempting to ping MarkMonitor"); - - _logger.LogInformation("Authentication with MarkMonitor API successful"); - - _logger.LogInformation("Attempting to list organizations"); - var orgs = await client.ListOrganizationsAsync(); - - if (orgs == null || !orgs.Any()) - throw new Exception("Unable to ping MarkMonitor API, or no MarkMonitor organization exist"); - _logger.LogInformation("Successfully pinged MarkMonitor API"); - } - catch (Exception e) - { - _logger.LogError("There was an error contacting MarkMonitor: {EMessage}", e.Message); - throw new Exception($"Error attempting to ping MarkMonitor: {e.Message}.", e); - } - finally - { - _logger.MethodExit(); - } - } - - public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) - { - _logger.MethodEntry(); - _logger.LogInformation("Validating CA Connection Info"); - - var errors = new List(); - - _logger.LogDebug("Checking the API Key"); - var apiKey = connectionInfo.TryGetValue(MarkMonitorConstants.ConfigConstants.ApiKey, out var aKey) - ? (string)aKey - : string.Empty; - if (string.IsNullOrWhiteSpace(apiKey)) - errors.Add($"A valid `{MarkMonitorConstants.ConfigConstants.ApiKey} is required"); - else _logger.LogDebug($"{MarkMonitorConstants.ConfigConstants.ApiKey} is set"); - - _logger.LogDebug("Checking the API service account password"); - var apiPassword = connectionInfo.TryGetValue(MarkMonitorConstants.ConfigConstants.ApiPassword, out var aPass) - ? (string)aPass - : string.Empty; - if (string.IsNullOrWhiteSpace(apiPassword)) - errors.Add($"A valid service account `{MarkMonitorConstants.ConfigConstants.ApiPassword}` is required"); - else _logger.LogDebug($"{MarkMonitorConstants.ConfigConstants.ApiPassword} is set"); - - _logger.LogDebug("Checking the API service account username"); - var apiUsername = connectionInfo.TryGetValue(MarkMonitorConstants.ConfigConstants.ApiUsername, out var aUser) - ? (string)aUser - : string.Empty; - if (string.IsNullOrWhiteSpace(apiUsername)) - errors.Add($"A valid service account `{MarkMonitorConstants.ConfigConstants.ApiUsername}` is required"); - else _logger.LogDebug($"{MarkMonitorConstants.ConfigConstants.ApiUsername} is set"); - _logger.LogTrace("MarkMonitor API Username: {Username}", apiUsername); - - _logger.LogDebug("Checking the API base URL"); - var baseURL = connectionInfo.TryGetValue(MarkMonitorConstants.ConfigConstants.BaseUrl, out var aUrl) - ? (string)aUrl - : string.Empty; - if (string.IsNullOrWhiteSpace(baseURL)) baseURL = "https://api.markmonitor.com"; - else if (!baseURL.Contains("http")) errors.Add("The Base URL needs http:// or https://"); - else _logger.LogDebug($"{MarkMonitorConstants.ConfigConstants.BaseUrl} is set"); - _logger.LogTrace("MarkMonitor API Base URL: {BaseURL}", baseURL); - - _logger.LogDebug("Checking the Organization Name"); - var orgName = connectionInfo.TryGetValue(MarkMonitorConstants.ConfigConstants.OrgName, out var aOrg) - ? (string)aOrg - : string.Empty; - if (string.IsNullOrWhiteSpace(orgName)) errors.Add("A valid Organization name is required"); - else _logger.LogDebug($"{MarkMonitorConstants.ConfigConstants.OrgName} is set"); - _logger.LogTrace("MarkMonitor Organization Name: {OrgName}", orgName); - if (errors.Any()) ThrowValidationException(errors); - _logger.LogInformation("CA Connection Info validated successfully"); - _logger.MethodExit(); - } - - public Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) - { - _logger.LogInformation("Product Info validated successfully"); - return Task.CompletedTask; - } - - public Dictionary GetCAConnectorAnnotations() - { - return MarkMonitorConstants.GetPluginAnnotations(); - } - - public Dictionary GetTemplateParameterAnnotations() - { - return MarkMonitorConstants.GetTemplateParameterAnnotations(); - } - - public List GetProductIds() - { - // return list of CertOrderTypes Enum values - return Enum.GetNames(typeof(CertOrderTypes)).ToList(); - } - - private async Task CreateAndAuthenticateClientAsync() - { - _logger.MethodEntry(); - var client = new MarkMonitorClient( - _config.BaseUrl, - _config.ApiKey, - _config.ApiUsername, - _config.ApiPassword, - true - ); - _logger.LogDebug("Authenticating with MarkMonitor API"); - _logger.LogTrace("MarkMonitor API Username: {Username}", _config.ApiUsername); - await client.AuthenticateAsync(); - _logger.MethodExit(); - return client; - } - - private void ThrowValidationException(List errors) - { - var validationMsg = $"Validation errors:\n{string.Join("\n", errors)}"; - throw new AnyCAValidationException(validationMsg); - } -} \ No newline at end of file diff --git a/markmonitor-cagateway/MarkMonitorCAPluginConfig.cs b/markmonitor-cagateway/MarkMonitorCAPluginConfig.cs deleted file mode 100644 index d7aa5de..0000000 --- a/markmonitor-cagateway/MarkMonitorCAPluginConfig.cs +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright 2024 Keyfactor -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using Keyfactor.AnyGateway.Extensions; -using Newtonsoft.Json; - -namespace Keyfactor.Extensions.CAPlugin.MarkMonitor; - -public class MarkMonitorCAPluginConfig -{ - public static Dictionary GetPluginAnnotations() - { - return new Dictionary - { - [ConfigConstants.ApiKey] = new() - { - Comments = "The API Key for the MarkMonitor API", - Hidden = true, - DefaultValue = "", - Type = "String" - }, - [ConfigConstants.ApiUsername] = new() - { - Comments = "Username for the MarkMonitor API service account", - Hidden = false, - DefaultValue = "", - Type = "String" - }, - [ConfigConstants.ApiPassword] = new() - { - Comments = "Password for the MarkMonitor API service account", - Hidden = true, - DefaultValue = "", - Type = "String" - }, - [ConfigConstants.BaseUrl] = new() - { - Comments = - "The Base URL for the MarkMonitor API - Usually either https://api.markmonitor.com", - Hidden = false, - DefaultValue = "https://api.markmonitor.com", - Type = "String" - }, - [ConfigConstants.OrgName] = new() - { - Comments = - "The name of the MarkMonitor Organization to use for the API calls (ex: MarkMonitor). You can also use the Organization ID in GUID format.", - Hidden = false, - DefaultValue = "", - Type = "String" - }, - [ConfigConstants.Enabled] = new() - { - Comments = - "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available.", - Hidden = false, - DefaultValue = true, - Type = "Boolean" - } - }; - } - - public static Dictionary GetTemplateParameterAnnotations() - { - return new Dictionary - { - [EnrollmentConfigConstants.CertificateValidityInYears] = new() - { - Comments = "Number of years the certificate will be valid for", - Hidden = false, - DefaultValue = "1", - Type = "Number" - }, - [EnrollmentConfigConstants.Email] = new() - { - Comments = "Email address of the requestor", - Hidden = false, - DefaultValue = "", - Type = "String" - }, - [EnrollmentConfigConstants.OrganizationName] = new() - { - Comments = "Name of the organization to be validated against", - Hidden = false, - DefaultValue = "", - Type = "String" - } - // [EnrollmentConfigConstants.OrganizationAddress] = new() - // { - // Comments = "Address of the organization to be validated against", - // Hidden = false, - // DefaultValue = "", - // Type = "String" - // }, - // [EnrollmentConfigConstants.OrganizationCity] = new() - // { - // Comments = "City of the organization to be validated against", - // Hidden = false, - // DefaultValue = "", - // Type = "String" - // }, - // [EnrollmentConfigConstants.OrganizationState] = new() - // { - // Comments = "Full state name of the organization to be validated against", - // Hidden = false, - // DefaultValue = "", - // Type = "String" - // }, - // [EnrollmentConfigConstants.OrganizationCountry] = new() - // { - // Comments = "2 character abbreviation of the country of the organization to be validated against", - // Hidden = false, - // DefaultValue = "", - // Type = "String" - // }, - // [EnrollmentConfigConstants.OrganizationPhone] = new() - // { - // Comments = "Phone number of the organization to be validated against", - // Hidden = false, - // DefaultValue = "", - // Type = "String" - // }, - // [EnrollmentConfigConstants.RegistrationAgent] = new() - // { - // Comments = - // "Registration agent name assigned to the organization when its documents were filed for registration", - // Hidden = false, - // DefaultValue = "", - // Type = "String" - // }, - // [EnrollmentConfigConstants.RegistrationNumber] = new() - // { - // Comments = - // "Registration number assigned to the organization when its documents were filed for registration", - // Hidden = false, - // DefaultValue = "", - // Type = "String" - // }, - // [EnrollmentConfigConstants.RootCAType] = new() - // { - // Comments = - // "The certificate's root CA - Depending on certificate expiration date, SHA_1 not be allowed. Will default to SHA_2 if expiration date exceeds sha1 allowed date. Options are MarkMonitor_SHA_1, MarkMonitor_SHA_2, STARFIELD_SHA_1, or STARFIELD_SHA_2.", - // Hidden = false, - // DefaultValue = "MarkMonitor_SHA_2", - // Type = "String" - // } - }; - } - - public class ConfigConstants - { - public const string ApiKey = "ApiKey"; - public const string ApiPassword = "Password"; - public const string ApiUsername = "Username"; - public const string BaseUrl = "BaseUrl"; - public const string OrgName = "OrgId"; - public const string Enabled = "Enabled"; - } - - public class Config - { - [JsonProperty(ConfigConstants.ApiKey)] public string ApiKey { get; set; } - - [JsonProperty(ConfigConstants.ApiPassword)] - public string ApiPassword { get; set; } - - [JsonProperty(ConfigConstants.ApiUsername)] - public string ApiUsername { get; set; } - - [JsonProperty(ConfigConstants.BaseUrl)] - public string BaseUrl { get; set; } - - [JsonProperty(ConfigConstants.OrgName)] - public string OrgName { get; set; } - - [JsonProperty(ConfigConstants.Enabled)] - public bool Enabled { get; set; } - } - - public static class EnrollmentConfigConstants - { - // public const string LastName = "LastName"; - // public const string FirstName = "FirstName"; - public const string Email = "Email"; - // public const string Phone = "Phone"; - - public const string OrganizationName = "OrganizationName"; - // public const string OrganizationAddress = "OrganizationAddress"; - // public const string OrganizationCity = "OrganizationCity"; - // public const string OrganizationState = "OrganizationState"; - // public const string OrganizationCountry = "OrganizationCountry"; - // public const string OrganizationPhone = "OrganizationPhone"; - - // public const string JobTitle = "JobTitle"; - // public const string RegistrationAgent = "RegistrationAgent"; - // public const string RegistrationNumber = "RegistrationNumber"; - // - // public const string RootCAType = "RootCAType"; - // public const string SlotSize = "SlotSize"; - public const string CertificateValidityInYears = "CertificateValidityInYears"; - } -} \ No newline at end of file diff --git a/markmonitor-cagateway/MarkMonitorConfig.cs b/markmonitor-cagateway/MarkMonitorConfig.cs deleted file mode 100644 index a917e72..0000000 --- a/markmonitor-cagateway/MarkMonitorConfig.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Newtonsoft.Json; - -namespace Keyfactor.Extensions.CAPlugin.MarkMonitor; - -public class MarkMonitorConfig -{ - [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.ApiKey)] - public string ApiKey { get; set; } - - [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.ApiPassword)] - public string ApiPassword { get; set; } - - [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.ApiUsername)] - public string ApiUsername { get; set; } - - [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.BaseUrl)] - public string BaseUrl { get; set; } - - [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.OrgName)] - public string OrgName { get; set; } - - [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.Enabled)] - public bool Enabled { get; set; } - - public string MarkMonitorApiClient { get; set; } -} \ No newline at end of file diff --git a/markmonitor-cagateway/Models/EnrollCertificateRequest.cs b/markmonitor-cagateway/Models/EnrollCertificateRequest.cs deleted file mode 100644 index a53d961..0000000 --- a/markmonitor-cagateway/Models/EnrollCertificateRequest.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Newtonsoft.Json; - -namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; - -public class EnrollCertificateRequest -{ - [JsonProperty("ca_id")] public int CaId { get; set; } - - [JsonProperty("cert_type")] public string CertType { get; set; } - - [JsonProperty("file_csr_encoding")] public string CsrEncoding { get; set; } - - [JsonProperty("issue_cert")] public bool IssueCert { get; set; } - - [JsonProperty("days")] public int Days { get; set; } - - [JsonProperty("file_csr")] public string Csr { get; set; } -} \ No newline at end of file diff --git a/markmonitor-cagateway/Models/IssuerDn.cs b/markmonitor-cagateway/Models/IssuerDn.cs deleted file mode 100644 index 1a078cf..0000000 --- a/markmonitor-cagateway/Models/IssuerDn.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Newtonsoft.Json; - -namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; - -public class IssuerDn -{ - [JsonProperty("C")] public string C { get; set; } - - [JsonProperty("L")] public string L { get; set; } - - [JsonProperty("O")] public string O { get; set; } - - [JsonProperty("CN")] public string Cn { get; set; } - - [JsonProperty("ST")] public string St { get; set; } - - [JsonProperty("emailAddress")] public string EmailAddress { get; set; } -} \ No newline at end of file diff --git a/markmonitor-cagateway/Models/MarkMonitorListCertificatesResponse.cs b/markmonitor-cagateway/Models/MarkMonitorListCertificatesResponse.cs deleted file mode 100644 index 71eb3a0..0000000 --- a/markmonitor-cagateway/Models/MarkMonitorListCertificatesResponse.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Newtonsoft.Json; - -namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; - -/// -/// Represents the response for a list of orders. -/// -public class MarkMonitorListOrdersResponse -{ - /// - /// Gets or sets the content of the response. - /// - [JsonProperty("content")] - public List Content { get; set; } - - /// - /// Gets or sets the pagination information. - /// - [JsonProperty("page")] - public MarkMonitorPageInfo MarkMonitorPage { get; set; } -} \ No newline at end of file diff --git a/markmonitor-cagateway/Models/MarkMonitorListContactsResponse.cs b/markmonitor-cagateway/Models/MarkMonitorListContactsResponse.cs deleted file mode 100644 index 4caad30..0000000 --- a/markmonitor-cagateway/Models/MarkMonitorListContactsResponse.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Newtonsoft.Json; - -namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; - -/// -/// Represents the response for listing contacts. -/// -public class MarkMonitorListContactsResponse -{ - /// - /// Gets or sets the content of the response. - /// - [JsonProperty("content")] - public List Content { get; set; } - - /// - /// Gets or sets the pagination information. - /// - [JsonProperty("page")] - public MarkMonitorPageInfo Page { get; set; } -} \ No newline at end of file diff --git a/markmonitor-cagateway/Models/MarkMonitorListOrgsResponse.cs b/markmonitor-cagateway/Models/MarkMonitorListOrgsResponse.cs deleted file mode 100644 index 7a24bc3..0000000 --- a/markmonitor-cagateway/Models/MarkMonitorListOrgsResponse.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Newtonsoft.Json; - -namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; - -/// -/// Represents the response for listing organizations. -/// -public class MarkMonitorListOrgsResponse -{ - /// - /// Gets or sets the content of the response. - /// - [JsonProperty("content")] - public List Content { get; set; } - - /// - /// Gets or sets the pagination information. - /// - [JsonProperty("page")] - public MarkMonitorPageInfo MarkMonitorPage { get; set; } -} \ No newline at end of file diff --git a/markmonitor-cagateway/Models/SubjectDn.cs b/markmonitor-cagateway/Models/SubjectDn.cs deleted file mode 100644 index 2be7f01..0000000 --- a/markmonitor-cagateway/Models/SubjectDn.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Newtonsoft.Json; - -namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; - -public class SubjectDn -{ - [JsonProperty("C")] public string C { get; set; } - - [JsonProperty("L")] public string L { get; set; } - - [JsonProperty("O")] public string O { get; set; } - - [JsonProperty("CN")] public string Cn { get; set; } - - [JsonProperty("ST")] public string St { get; set; } - - [JsonProperty("emailAddress")] public string EmailAddress { get; set; } -} \ No newline at end of file diff --git a/markmonitor-cagateway/Models/TokenRequest.cs b/markmonitor-cagateway/Models/TokenRequest.cs deleted file mode 100644 index 287f9ab..0000000 --- a/markmonitor-cagateway/Models/TokenRequest.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Newtonsoft.Json; - -namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; - -public class TokenRequest -{ - [JsonProperty("username")] public string Username { get; set; } - - [JsonProperty("password")] public string Password { get; set; } -} \ No newline at end of file diff --git a/markmonitor-cagateway/Models/TokenResponse.cs b/markmonitor-cagateway/Models/TokenResponse.cs deleted file mode 100644 index 6f033d9..0000000 --- a/markmonitor-cagateway/Models/TokenResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Newtonsoft.Json; - -namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; - -public class TokenResponse -{ - [JsonProperty("token")] public string BearerToken { get; set; } - - [JsonProperty("expires_in")] public int ExpiresIn { get; set; } -} \ No newline at end of file diff --git a/markmonitor-cagateway/markmonitor-cagateway.csproj b/markmonitor-cagateway/markmonitor-cagateway.csproj deleted file mode 100644 index b466242..0000000 --- a/markmonitor-cagateway/markmonitor-cagateway.csproj +++ /dev/null @@ -1,33 +0,0 @@ - - - - net6.0 - Keyfactor.Extensions.CAPlugin.MarkMonitor - enable - disable - MarkMonitorCAPlugin - true - - - false - Keyfactor Inc - MarkMonitor CA Gateway - 1.0.0.0 - 1.0.0.0 - - - - - - - - - - - - - Always - - - - diff --git a/markmonitor-cagateway/packages.config b/markmonitor-cagateway/packages.config deleted file mode 100644 index b5aed46..0000000 --- a/markmonitor-cagateway/packages.config +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/markmonitor-caplugin.IntegrationTests/Client/MarkMonitorClientAuthenticateLiveTests.cs b/markmonitor-caplugin.IntegrationTests/Client/MarkMonitorClientAuthenticateLiveTests.cs new file mode 100644 index 0000000..cdad912 --- /dev/null +++ b/markmonitor-caplugin.IntegrationTests/Client/MarkMonitorClientAuthenticateLiveTests.cs @@ -0,0 +1,40 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.IntegrationTests.Client; + +// Hits the real MarkMonitor API using credentials from the environment (see .env / TestConsole/.env). +// Skips automatically when those variables aren't set, so it stays out of normal CI/unit runs. +public class MarkMonitorClientAuthenticateLiveTests +{ + [Fact] + public async Task AuthenticateAsync_WithLiveCredentials_Succeeds() + { + if (!LiveApiCredentials.TryGet(out var baseUrl, out var apiToken, out var username, out var password)) + { + // No live credentials in the environment; nothing to verify. + return; + } + + using var client = new MarkMonitorClient(baseUrl, apiToken, username, password); + + // AuthenticateAsync throws on a failed authentication, so "did not throw" is the real + // assertion here - mirroring TestConsole's TestAuthenticate. + var exception = await Record.ExceptionAsync(() => client.AuthenticateAsync()); + + Assert.Null(exception); + } +} diff --git a/markmonitor-caplugin.IntegrationTests/Client/MarkMonitorClientEnrollLiveTests.cs b/markmonitor-caplugin.IntegrationTests/Client/MarkMonitorClientEnrollLiveTests.cs new file mode 100644 index 0000000..959fac4 --- /dev/null +++ b/markmonitor-caplugin.IntegrationTests/Client/MarkMonitorClientEnrollLiveTests.cs @@ -0,0 +1,104 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; +using Keyfactor.PKI.Enums.EJBCA; +using Keyfactor.PKI.PEM; +using TestConsole.Helpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.IntegrationTests.Client; + +// Hits the real MarkMonitor API using credentials from the environment (see .env / TestConsole/.env) +// and creates real, billable MarkMonitor orders. Skips automatically when those variables aren't +// set, so it stays out of normal CI/unit runs. +// +// Unlike TestConsole/Program.cs's EnrollCertificateAsync loop (which supports +// MARKMONITOR_SKIP_CLEANUP for deliberate manual-inspection runs), every order created here is +// always cleaned up (see OrderCleanup) - this suite is meant to run unattended and repeatedly, so +// leaving created orders in place is never an option. +public class MarkMonitorClientEnrollLiveTests +{ + [Fact] + public async Task EnrollCertificateAsync_WithRsaCsr_CreatesAndCleansUpOrder() + { + await RunEnrollTest(CsrGenerator.KeyType.RSA); + } + + [Fact] + public async Task EnrollCertificateAsync_WithEccCsr_CreatesAndCleansUpOrder() + { + await RunEnrollTest(CsrGenerator.KeyType.ECC); + } + + private static async Task RunEnrollTest(CsrGenerator.KeyType keyType) + { + if (!LiveApiCredentials.TryGet(out var baseUrl, out var apiToken, out var username, out var password)) + { + // No live credentials in the environment; nothing to verify. + return; + } + + using var client = new MarkMonitorClient(baseUrl, apiToken, username, password); + await client.AuthenticateAsync(); + + var orgs = await client.ListOrganizationsAsync(0, 0); + Assert.NotEmpty(orgs); + + var csrGenerator = new CsrGenerator(); + var generatedCsrs = await csrGenerator.GenerateCsrs(1, keyType); + var (csr, _, _) = generatedCsrs[0]; + var csrPem = PemUtilities.DERToPEM(csr.GetEncoded(), PemUtilities.PemObjectType.CertRequest); + var commonName = csr.GetCertificationRequestInfo().Subject.GetValueList()[0]; + + var randomNumberOfEmails = new Random().Next(1, 4); + var additionalEmails = await EmailAddressGenerator.GenerateRandomEmailsAsync(randomNumberOfEmails); + + var config = new MarkMonitorConfig + { + ApiKey = apiToken, + ApiUsername = username, + ApiPassword = password, + BaseUrl = baseUrl, + OrgName = orgs[0].Name, + Enabled = true + }; + var productParams = new Dictionary + { + ["additionalEmails"] = string.Join(",", additionalEmails) + }; + + EnrollmentResult? enrollResult = null; + try + { + enrollResult = await client.EnrollCertificateAsync(csrPem, $"CN={commonName}", + new Dictionary(), CertOrderTypes.SslDvGeotrust.ToString(), productParams, config); + + Assert.NotNull(enrollResult); + Assert.False(string.IsNullOrWhiteSpace(enrollResult.CARequestID)); + // The test org used for these live runs requires manual email DCV approval, so the + // order will be pending (EXTERNALVALIDATION), not issued, by the time this returns - + // the real assertion is that the order was accepted at all rather than rejected/failed. + Assert.NotEqual((int)EndEntityStatus.FAILED, enrollResult.Status); + } + finally + { + if (enrollResult != null) + { + await OrderCleanup.CleanUpOrderAsync(client, enrollResult.CARequestID, config.OrgName); + } + } + } +} diff --git a/markmonitor-caplugin.IntegrationTests/Client/MarkMonitorClientListCertificateOrdersLiveTests.cs b/markmonitor-caplugin.IntegrationTests/Client/MarkMonitorClientListCertificateOrdersLiveTests.cs new file mode 100644 index 0000000..38f515f --- /dev/null +++ b/markmonitor-caplugin.IntegrationTests/Client/MarkMonitorClientListCertificateOrdersLiveTests.cs @@ -0,0 +1,48 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.IntegrationTests.Client; + +// Hits the real MarkMonitor API using credentials from the environment (see .env / TestConsole/.env). +// Skips automatically when those variables aren't set, so it stays out of normal CI/unit runs. +public class MarkMonitorClientListCertificateOrdersLiveTests +{ + [Fact] + public async Task ListCertificateOrdersAsync_WithLiveCredentials_ReturnsOrders() + { + if (!LiveApiCredentials.TryGet(out var baseUrl, out var apiToken, out var username, out var password)) + { + // No live credentials in the environment; nothing to verify. + return; + } + + using var client = new MarkMonitorClient(baseUrl, apiToken, username, password); + await client.AuthenticateAsync(); + + var orders = await client.ListCertificateOrdersAsync(0, "", "", 100); + + // Mirrors TestConsole's TestListCertificateOrders, which treats an empty result as a + // test-setup problem ("no certificates found, please add some to run this test") rather + // than a valid outcome. + Assert.NotEmpty(orders); + + foreach (var order in orders) + { + Assert.False(string.IsNullOrWhiteSpace(order.Id)); + Assert.False(string.IsNullOrWhiteSpace(order.Status)); + } + } +} diff --git a/markmonitor-caplugin.IntegrationTests/Client/MarkMonitorClientListOrgsLiveTests.cs b/markmonitor-caplugin.IntegrationTests/Client/MarkMonitorClientListOrgsLiveTests.cs new file mode 100644 index 0000000..445313f --- /dev/null +++ b/markmonitor-caplugin.IntegrationTests/Client/MarkMonitorClientListOrgsLiveTests.cs @@ -0,0 +1,56 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.IntegrationTests.Client; + +// Hits the real MarkMonitor API using credentials from the environment (see .env / TestConsole/.env). +// Skips automatically when those variables aren't set, so it stays out of normal CI/unit runs. +public class MarkMonitorClientListOrgsLiveTests +{ + [Fact] + public async Task ListOrganizationsAsync_WithLiveCredentials_ReturnsOrgsWithValidations() + { + if (!LiveApiCredentials.TryGet(out var baseUrl, out var apiToken, out var username, out var password)) + { + // No live credentials in the environment; nothing to verify. + return; + } + + using var client = new MarkMonitorClient(baseUrl, apiToken, username, password); + await client.AuthenticateAsync(); + + var orgs = await client.ListOrganizationsAsync(0, 0); + + // Mirrors TestConsole's TestListOrgs, which treats an empty result as a test-setup problem + // ("no organizations found, please add some to run this test") rather than a valid outcome. + Assert.NotEmpty(orgs); + + foreach (var org in orgs) + { + Assert.False(string.IsNullOrWhiteSpace(org.Id)); + + // TestListOrgs additionally logs each org's validations (name/type) - assert their shape + // is well-formed rather than just that the call didn't throw. `name` is not always + // present on real data - a DV-type validation, for example, comes back with only `type` + // and no `name` field at all - so only `type` is guaranteed non-empty. + foreach (var validation in org.Validations ?? new List()) + { + Assert.False(string.IsNullOrWhiteSpace(validation.Type)); + } + } + } +} diff --git a/markmonitor-caplugin.IntegrationTests/LiveApiCredentials.cs b/markmonitor-caplugin.IntegrationTests/LiveApiCredentials.cs new file mode 100644 index 0000000..969ad30 --- /dev/null +++ b/markmonitor-caplugin.IntegrationTests/LiveApiCredentials.cs @@ -0,0 +1,35 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.IntegrationTests; + +/// +/// Reads the MARKMONITOR_* environment variables that every live-API test in this project needs +/// (see .env / TestConsole/.env, sourced manually - there is no dotenv-loading package in this +/// repo). Every test in this project must skip (return early) rather than fail when these aren't +/// set, so the whole suite stays safe to run without live credentials present. +/// +internal static class LiveApiCredentials +{ + public static bool TryGet(out string? baseUrl, out string? apiToken, out string? username, out string? password) + { + baseUrl = Environment.GetEnvironmentVariable("MARKMONITOR_BASE_URL"); + apiToken = Environment.GetEnvironmentVariable("MARKMONITOR_API_TOKEN"); + username = Environment.GetEnvironmentVariable("MARKMONITOR_USERNAME"); + password = Environment.GetEnvironmentVariable("MARKMONITOR_PASSWORD"); + + return !string.IsNullOrEmpty(baseUrl) && !string.IsNullOrEmpty(apiToken) && + !string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password); + } +} diff --git a/markmonitor-caplugin.IntegrationTests/OrderCleanup.cs b/markmonitor-caplugin.IntegrationTests/OrderCleanup.cs new file mode 100644 index 0000000..5663b60 --- /dev/null +++ b/markmonitor-caplugin.IntegrationTests/OrderCleanup.cs @@ -0,0 +1,51 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.IntegrationTests; + +/// +/// Cleanup for a real, billable MarkMonitor order created by a live enrollment test. Mirrors +/// TestConsole/Program.cs's CleanUpOrderAsync (cancel, falling back to revoke on failure) - orders +/// created by these tests are freshly submitted and never reach an issued state before this runs. +/// +/// Unlike TestConsole (a manual tool with a MARKMONITOR_SKIP_CLEANUP escape hatch for deliberate +/// inspection runs), this suite is meant to run unattended and repeatedly, so there is no opt-out: +/// a cleanup failure here throws (failing the test loudly) rather than just logging a warning, +/// since a silently-failed cleanup would otherwise leave a billable order behind with nothing +/// flagging it for manual follow-up. +/// +internal static class OrderCleanup +{ + public static async Task CleanUpOrderAsync(MarkMonitorClient client, string orderId, string? orgName = null) + { + try + { + await client.CancelCertificateAsync(orderId, orgName); + } + catch (Exception cancelEx) + { + try + { + await client.RevokeCertificateAsync(orderId, orgName); + } + catch (Exception revokeEx) + { + throw new Exception( + $"Could not cancel or revoke order {orderId} - it may still incur charges and must be cleaned up manually. Cancel error: {cancelEx.Message}; Revoke error: {revokeEx.Message}"); + } + } + } +} diff --git a/markmonitor-caplugin.IntegrationTests/README.md b/markmonitor-caplugin.IntegrationTests/README.md new file mode 100644 index 0000000..b063bd2 --- /dev/null +++ b/markmonitor-caplugin.IntegrationTests/README.md @@ -0,0 +1,26 @@ +# markmonitor-caplugin.IntegrationTests + +xUnit suite that hits the **real** MarkMonitor API - authenticate, list organizations, list +certificate orders, and enroll with an RSA and an ECC CSR. Every test reads the same four +`MARKMONITOR_*` environment variables `TestConsole` uses and returns immediately (a silent pass, +not a skip/failure) when they're unset, so this project is always safe to run, including in CI +with no credentials present. + +``` +MARKMONITOR_BASE_URL +MARKMONITOR_API_TOKEN +MARKMONITOR_USERNAME +MARKMONITOR_PASSWORD +``` + +```shell +set -a && source .env && set +a # or TestConsole/.env +dotnet test markmonitor-caplugin.IntegrationTests -c Release +``` + +Each enrollment test always cancels (falling back to revoke) the order it creates - there is no +`MARKMONITOR_SKIP_CLEANUP` escape hatch here, unlike `TestConsole`. A cleanup failure throws rather +than logging a warning, so a billable order is never left behind silently. + +See [DEVELOPMENT.md](../DEVELOPMENT.md#live-integration-tests-markmonitor-caplugin-integrationtests) +for the full picture, including the manual `workflow_dispatch`-gated CI job. diff --git a/markmonitor-caplugin.IntegrationTests/markmonitor-caplugin.IntegrationTests.csproj b/markmonitor-caplugin.IntegrationTests/markmonitor-caplugin.IntegrationTests.csproj new file mode 100644 index 0000000..5299b5b --- /dev/null +++ b/markmonitor-caplugin.IntegrationTests/markmonitor-caplugin.IntegrationTests.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + Keyfactor.Extensions.CAPlugin.MarkMonitor.IntegrationTests + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientAdditionalEmailsTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientAdditionalEmailsTests.cs new file mode 100644 index 0000000..948a2b7 --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientAdditionalEmailsTests.cs @@ -0,0 +1,50 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; +using Newtonsoft.Json.Linq; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +public class MarkMonitorClientAdditionalEmailsTests +{ + [Fact] + public async Task EnrollCertificateAsync_WithSpaceAndCommaSeparatedEmails_DoesNotSendEmptyEntries() + { + // "a@b.com, c@d.com".Replace(" ", ",").Split(',') used to yield ["a@b.com", "", "c@d.com"] - + // an empty string sent to MarkMonitor as one of the additional emails. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("11111111-1111-1111-1111-111111111111", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var config = SampleConfig.Default(); + var productParams = new Dictionary { ["additionalEmails"] = "a@b.com, c@d.com" }; + + await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", productParams, config); + + var orderRequest = Assert.Single(handler.Requests, req => FakeHttpMessageHandler.Is(req, "POST", "/order")); + var body = await orderRequest.Content!.ReadAsStringAsync(); + var sentEmails = JObject.Parse(body)["additionalEmails"]!.Values().ToList(); + Assert.Equal(new[] { "a@b.com", "c@d.com" }, sentEmails); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientAuthLifecycleTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientAuthLifecycleTests.cs new file mode 100644 index 0000000..9028df8 --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientAuthLifecycleTests.cs @@ -0,0 +1,144 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +[Collection(LogHandlerFactoryCollection.Name)] +public class MarkMonitorClientAuthLifecycleTests +{ + private static int AuthCallCount(FakeHttpMessageHandler handler) => + handler.Requests.Count(r => FakeHttpMessageHandler.Is(r, "POST", "/auth/v1/auth/authenticate")); + + [Fact] + public async Task ListOrganizationsAsync_WithoutAnyPriorAuthenticateCall_LazilyAuthenticatesRatherThanThrowing() + { + // Before the fix, EnsureAuthenticated() called AuthenticateAsync().RunSynchronously(), which + // throws InvalidOperationException on any Task returned from an async method. Every existing + // call site happened to pre-await AuthenticateAsync() so this never surfaced - calling a method + // WITHOUT authenticating first is exactly the case that used to blow up. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))); + var client = handler.BuildClient(); + + var orgs = await client.ListOrganizationsAsync(); + + Assert.NotNull(orgs); + Assert.Single(orgs); + Assert.Equal(1, AuthCallCount(handler)); + } + + [Fact] + public async Task EnsureAuthenticatedAsync_WhenTokenHasExpired_ReAuthenticatesBeforeTheNextCall() + { + var clock = new ManualTimeProvider { UtcNow = DateTimeOffset.UtcNow }; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth(expiresIn: 60) + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))); + var client = handler.BuildClient(clock); + + await client.AuthenticateAsync(); + Assert.Equal(1, AuthCallCount(handler)); + + // Token is valid for 60s minus a 30s safety buffer = 30s. Move well past that. + clock.UtcNow = clock.UtcNow.AddSeconds(31); + + await client.ListOrganizationsAsync(); + + Assert.Equal(2, AuthCallCount(handler)); + } + + [Fact] + public async Task EnsureAuthenticatedAsync_WhenTokenIsStillValid_DoesNotReAuthenticate() + { + var clock = new ManualTimeProvider { UtcNow = DateTimeOffset.UtcNow }; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth(expiresIn: 3600) + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))); + var client = handler.BuildClient(clock); + + await client.AuthenticateAsync(); + clock.UtcNow = clock.UtcNow.AddSeconds(5); + await client.ListOrganizationsAsync(); + + Assert.Equal(1, AuthCallCount(handler)); + } + + [Fact] + public async Task EnsureAuthenticatedAsync_CalledConcurrentlyWithAnExpiredToken_OnlyAuthenticatesOnce() + { + // Without a lock, two callers can both see the expired token, both call AuthenticateAsync + // concurrently, and race writing _bearerToken/_tokenExpiresAtUtc and the shared HttpClient's + // Authorization header. Gate the auth response so both calls are genuinely in flight together + // rather than sequential. + var clock = new ManualTimeProvider { UtcNow = DateTimeOffset.UtcNow }; + var authGate = new TaskCompletionSource(); + var handler = new FakeHttpMessageHandler() + .WhenGated(req => FakeHttpMessageHandler.Is(req, "POST", "/auth/v1/auth/authenticate"), authGate.Task, + FakeHttpMessageHandler.Json(HttpStatusCode.OK, """{"token":"fake-token","expiresIn":3600}""")) + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))); + var client = handler.BuildClient(clock); + + var firstCall = client.ListOrganizationsAsync(); + var secondCall = client.ListOrganizationsAsync(); + authGate.SetResult(); + await Task.WhenAll(firstCall, secondCall); + + Assert.Equal(1, AuthCallCount(handler)); + } + + [Fact] + public async Task EnsureAuthenticatedAsync_WaiterFindsTokenAlreadyRefreshed_LogsThatItReusedIt() + { + // The caller that wins _authLock and re-authenticates logs about it, but the caller that was + // blocked on the lock previously returned silently once it acquired the lock and found + // TokenNeedsRefresh() false - no signal that it reused a token a concurrent caller just + // refreshed. Assert the waiter now logs that too. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + + var clock = new ManualTimeProvider { UtcNow = DateTimeOffset.UtcNow }; + var authGate = new TaskCompletionSource(); + var handler = new FakeHttpMessageHandler() + .WhenGated(req => FakeHttpMessageHandler.Is(req, "POST", "/auth/v1/auth/authenticate"), authGate.Task, + FakeHttpMessageHandler.Json(HttpStatusCode.OK, """{"token":"fake-token","expiresIn":3600}""")) + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))); + var client = handler.BuildClient(clock); + + // The first call wins the lock and blocks on the gated auth response; the second call + // blocks on _authLock.WaitAsync() until the first releases it, then must find the token + // already refreshed. + var firstCall = client.ListOrganizationsAsync(); + var secondCall = client.ListOrganizationsAsync(); + authGate.SetResult(); + await Task.WhenAll(firstCall, secondCall); + + Assert.Equal(1, AuthCallCount(handler)); + Assert.Contains(capturingFactory.Messages, + m => m.Contains("refreshed by a concurrent caller", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientAuthenticateTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientAuthenticateTests.cs new file mode 100644 index 0000000..9d72e1d --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientAuthenticateTests.cs @@ -0,0 +1,161 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +[Collection(LogHandlerFactoryCollection.Name)] +public class MarkMonitorClientAuthenticateTests +{ + [Fact] + public async Task AuthenticateAsync_WithFakeSuccessResponse_Succeeds() + { + var handler = new FakeHttpMessageHandler().WithSuccessfulAuth(); + var client = handler.BuildClient(); + + await client.AuthenticateAsync(); + + var authRequest = Assert.Single(handler.Requests); + Assert.Equal("POST", authRequest.Method.Method); + Assert.Contains("/auth/v1/auth/authenticate", authRequest.RequestUri!.ToString()); + } + + [Fact] + public async Task AuthenticateAsync_WithFailureResponse_ThrowsWithParsedErrorInsteadOfRawHttpException() + { + // Before the fix, EnsureSuccessStatusCode() was called before the response body was ever + // read, so it always threw a bare HttpRequestException ("Response status code does not + // indicate success: 401") - the code below it that builds a real error message from the + // response body was unreachable dead code. + var handler = new FakeHttpMessageHandler() + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/auth/v1/auth/authenticate"), + FakeHttpMessageHandler.Json(HttpStatusCode.Unauthorized, + """{"errors":[{"code":"auth.invalidCredentials","message":"Invalid username or password."}]}""")); + var client = handler.BuildClient(); + + var ex = await Assert.ThrowsAsync(() => client.AuthenticateAsync()); + + Assert.IsNotType(ex); + Assert.Contains("Invalid username or password", ex.Message); + } + + [Fact] + public async Task AuthenticateAsync_CalledTwice_DoesNotDuplicateTheApiKeyHeader() + { + // DefaultRequestHeaders.Add() does not replace an existing value for the same header name - + // calling AuthenticateAsync a second time on the same client (now a real path, since + // EnsureAuthenticatedAsync re-authenticates on token expiry) used to leave two X-API-KEY + // values on every subsequent request. + var handler = new FakeHttpMessageHandler().WithSuccessfulAuth(); + var client = handler.BuildClient(); + + await client.AuthenticateAsync(); + await client.AuthenticateAsync(); + + var lastRequest = handler.Requests.Last(); + var apiKeyValues = lastRequest.Headers.GetValues("X-API-KEY").ToList(); + Assert.Single(apiKeyValues); + Assert.Equal("key", apiKeyValues[0]); + } + + [Fact] + public async Task AuthenticateAsync_WithASuccessStatusButAnEmptyBody_LogsAuthenticationFailedForTheUsername() + { + // Regression test: a 2xx response whose body is empty/malformed deserializes to null (or + // throws on malformed JSON) without ever reaching the else-branch's own "Authentication + // failed" log - neither of AuthenticateAsync's other two failure-logging sites covered this + // case, so it used to surface only as a generic, identity-less error from whichever caller's + // catch block received the exception instead of this method's own identity-tagged record. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + + var handler = new FakeHttpMessageHandler() + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/auth/v1/auth/authenticate"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "null")); + var client = handler.BuildClient(); + + await Assert.ThrowsAnyAsync(() => client.AuthenticateAsync()); + + Assert.Contains(capturingFactory.Messages, + m => m.Contains("Authentication failed for", StringComparison.Ordinal) && + m.Contains("user", StringComparison.Ordinal)); + } + + [Fact] + public async Task AuthenticateAsync_WithASuccessStatusButNoTokenField_LogsAuthenticationFailedForTheUsername() + { + // Regression test: a well-formed 2xx JSON body simply missing (or empty on) the "token" field + // deserializes successfully to a non-null TokenResponse with BearerToken null - the null-body + // guard alone didn't catch this, so it used to fall through, set an empty/absent bearer token, + // and log "Authentication successful" instead of failing loudly. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + + var handler = new FakeHttpMessageHandler() + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/auth/v1/auth/authenticate"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var client = handler.BuildClient(); + + await Assert.ThrowsAnyAsync(() => client.AuthenticateAsync()); + + Assert.Contains(capturingFactory.Messages, + m => m.Contains("Authentication failed for", StringComparison.Ordinal) && + m.Contains("user", StringComparison.Ordinal)); + Assert.DoesNotContain(capturingFactory.Messages, + m => m.Contains("Authentication successful", StringComparison.Ordinal)); + } + + [Fact] + public async Task AuthenticateAsync_WithMissingConfig_LogsAuthenticationFailedForTheUsername() + { + // Regression test: ValidateConfiguration()'s throw used to sit outside this method's try/catch + // entirely, so a missing ApiKey/Username/Password (e.g. Enabled=true with a blank ApiPassword, + // a state the plugin's own config comments explicitly anticipate) produced zero identity- + // tagged authentication-failure record from this method - only whichever generic, identity- + // less message an enclosing caller's own catch happened to log. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + + var handler = new FakeHttpMessageHandler(); + var client = new MarkMonitorClient("https://api.markmonitor.test", "key", "user", "", true, handler); + + await Assert.ThrowsAnyAsync(() => client.AuthenticateAsync()); + + Assert.Contains(capturingFactory.Messages, + m => m.Contains("Authentication failed for", StringComparison.Ordinal) && + m.Contains("user", StringComparison.Ordinal) && + m.Contains("Password is required", StringComparison.Ordinal)); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task AuthenticateAsync_WhenTheAuthEndpointFails_DoesNotRetry() + { + // Regression test: AuthenticateAsync runs entirely inside EnsureAuthenticatedAsync's shared + // _authLock, held by every other concurrent Enroll/Revoke/Sync call on the same cached client + // that also needs a token. Retrying here (as SendWithRetryAsync would) multiplies the + // worst-case lock-hold time up to 3x a full HTTP timeout plus backoff during exactly the + // "MarkMonitor is degraded" scenario where that matters most - a single attempt here is + // deliberate, not an oversight. + var handler = new FakeHttpMessageHandler() + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/auth/v1/auth/authenticate"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, + """{"errors":[{"code":"request.genericError","message":"An unexpected error occurred."}]}""")); + var client = handler.BuildClient(); + + await Assert.ThrowsAnyAsync(() => client.AuthenticateAsync()); + + Assert.Equal(1, handler.Requests.Count(r => FakeHttpMessageHandler.Is(r, "POST", "/auth/v1/auth/authenticate"))); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientCancelTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientCancelTests.cs new file mode 100644 index 0000000..cef545c --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientCancelTests.cs @@ -0,0 +1,132 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +public class MarkMonitorClientCancelTests +{ + [Fact] + public async Task CancelCertificateAsync_SendsAValidJsonBody() + { + // Confirmed against the live MarkMonitor sandbox: PATCH .../cancel with an empty string body + // (not valid JSON) fails with a vague "Error retrieving order information (order.getError)". + // The exact same order succeeded immediately when PATCHed with "{}" instead. An empty string + // is not the same as an empty JSON object as far as MarkMonitor's API is concerned. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", "/certs/v1/order/11111111-1111-1111-1111-111111111111/cancel"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.CancelCertificateAsync("11111111-1111-1111-1111-111111111111"); + + Assert.True(result); + var cancelRequest = + Assert.Single(handler.Requests, req => FakeHttpMessageHandler.Is(req, "PATCH", "/cancel")); + var body = await cancelRequest.Content!.ReadAsStringAsync(); + Assert.Equal("{}", body); + } + + [Fact] + public async Task RevokeCertificateAsync_SendsAValidJsonBody() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", "/certs/v1/order/11111111-1111-1111-1111-111111111111/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.RevokeCertificateAsync("11111111-1111-1111-1111-111111111111"); + + Assert.True(result); + var revokeRequest = + Assert.Single(handler.Requests, req => FakeHttpMessageHandler.Is(req, "PATCH", "/revoke")); + var body = await revokeRequest.Content!.ReadAsStringAsync(); + Assert.Equal("{}", body); + } + + [Fact] + public async Task CancelCertificateAsync_WithMatchingOrganization_Succeeds() + { + // Mirrors RevokeCertificateAsync's cross-org ownership check (see + // MarkMonitorClientRevokeTests) - CancelCertificateAsync must apply the identical check. + const string orderId = "11111111-1111-1111-1111-111111111111"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{orderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert(orderId, "DIGI_ISSUED"))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{orderId}/cancel"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.CancelCertificateAsync(orderId, "Test Org"); + + Assert.True(result); + } + + [Fact] + public async Task CancelCertificateAsync_WhenTheOrderBelongsToADifferentOrganization_ThrowsWithoutCancelling() + { + const string orderId = "11111111-1111-1111-1111-111111111111"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{orderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert(orderId, "DIGI_ISSUED", + organizationId: "99999999-9999-9999-9999-999999999999"))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{orderId}/cancel"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.CancelCertificateAsync(orderId, "Test Org")); + + Assert.DoesNotContain(handler.Requests, r => FakeHttpMessageHandler.Is(r, "PATCH", "/cancel")); + } + + [Fact] + public async Task CancelCertificateAsync_WithNoOrganizationGiven_ProceedsWithoutTheOwnershipCheck() + { + // A blank orgName intentionally skips the ownership check (ad-hoc/manual callers that don't + // scope by organization) - matching RevokeCertificateAsync's existing behavior for this case. + const string orderId = "11111111-1111-1111-1111-111111111111"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{orderId}/cancel"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.CancelCertificateAsync(orderId); + + Assert.True(result); + // No organization lookup or order fetch should happen when orgName is blank. + Assert.DoesNotContain(handler.Requests, r => FakeHttpMessageHandler.Is(r, "GET", "/certs/v1/organization")); + Assert.DoesNotContain(handler.Requests, r => FakeHttpMessageHandler.Is(r, "GET", $"/certs/v1/order/{orderId}")); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientCleanSubjectTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientCleanSubjectTests.cs new file mode 100644 index 0000000..d6581ff --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientCleanSubjectTests.cs @@ -0,0 +1,71 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; +using Newtonsoft.Json.Linq; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +public class MarkMonitorClientCleanSubjectTests +{ + private static async Task EnrollAndGetSentCommonName(string subject) + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("11111111-1111-1111-1111-111111111111", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var config = SampleConfig.Default(); + + await client.EnrollCertificateAsync(SampleCsr.Pem, subject, new Dictionary(), + "SslDvGeotrust", new Dictionary(), config); + + var orderRequest = handler.Requests.Single(req => FakeHttpMessageHandler.Is(req, "POST", "/order")); + var body = await orderRequest.Content!.ReadAsStringAsync(); + return JObject.Parse(body)["cert"]!["commonName"]!.Value()!; + } + + [Fact] + public async Task EnrollCertificateAsync_WithCommaEscapedInCommonName_DoesNotTruncateAtTheEscapedComma() + { + // Plain IndexOf(",") string-slicing would cut this off at "Doe" instead of the real CN + // "Doe, John" - X509Name's RFC 2253 parser handles the backslash escape correctly. + var commonName = await EnrollAndGetSentCommonName("CN=Doe\\, John,O=Acme"); + + Assert.Equal("Doe, John", commonName); + } + + [Fact] + public async Task EnrollCertificateAsync_WithSimpleCommonName_StillWorks() + { + var commonName = await EnrollAndGetSentCommonName("CN=test.mmcertdomain.com"); + + Assert.Equal("test.mmcertdomain.com", commonName); + } + + [Fact] + public async Task EnrollCertificateAsync_WithMultiRdnSubject_ExtractsJustTheCommonName() + { + var commonName = await EnrollAndGetSentCommonName("CN=test.mmcertdomain.com,O=Acme,C=US"); + + Assert.Equal("test.mmcertdomain.com", commonName); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientDisposeTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientDisposeTests.cs new file mode 100644 index 0000000..d627528 --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientDisposeTests.cs @@ -0,0 +1,32 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +public class MarkMonitorClientDisposeTests +{ + [Fact] + public async Task Dispose_DisposesTheUnderlyingHttpClient() + { + var handler = new FakeHttpMessageHandler().WithSuccessfulAuth(); + var client = handler.BuildClient(); + + client.Dispose(); + + await Assert.ThrowsAsync(() => client.AuthenticateAsync()); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientEnrollIdempotencyTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientEnrollIdempotencyTests.cs new file mode 100644 index 0000000..b494721 --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientEnrollIdempotencyTests.cs @@ -0,0 +1,425 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +[Collection(LogHandlerFactoryCollection.Name)] +public class MarkMonitorClientEnrollIdempotencyTests +{ + private static MarkMonitorConfig Config() => SampleConfig.Default(); + + private static int OrderCreationCount(FakeHttpMessageHandler handler) => + handler.Requests.Count(r => FakeHttpMessageHandler.Is(r, "POST", "/certs/v1/order")); + + private static FakeHttpMessageHandler BuildHandler(string orderId = "11111111-1111-1111-1111-111111111111") => + new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, SampleOrders.OrderWithCert(orderId, "CREATED"))); + + [Fact] + public async Task EnrollCertificateAsync_CalledTwiceWithTheSameCsrAndSubject_OnlyCreatesOneOrder() + { + // Simulates Command retrying an Enroll call whose first attempt actually succeeded server- + // side but whose response was lost (timeout, dropped connection). + var handler = BuildHandler(); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var first = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + var second = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + Assert.Equal(1, OrderCreationCount(handler)); + Assert.Equal(first.CARequestID, second.CARequestID); + } + + [Fact] + public async Task EnrollCertificateAsync_CalledTwiceWithDifferentCsrs_CreatesTwoOrders() + { + var handler = BuildHandler(); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + await client.EnrollCertificateAsync(SampleCsr2.Pem, "CN=other.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + Assert.Equal(2, OrderCreationCount(handler)); + } + + [Fact] + public async Task EnrollCertificateAsync_CalledTwiceWithTheSameCsrAndSubject_DedupeLogIncludesResolvedCARequestID() + { + // Regression test for https://github.com/Keyfactor/markmonitor-caplugin/issues/7 - the + // dedup-hit warning used to be logged before awaiting the in-flight reservation, so it could + // never report the CARequestID the caller was actually folded into. It must now be logged + // after the reservation resolves, and must include that CARequestID. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + + var handler = BuildHandler(); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var first = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + var second = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + Assert.Equal(1, OrderCreationCount(handler)); + Assert.Equal(first.CARequestID, second.CARequestID); + + var dedupeLog = Assert.Single(capturingFactory.Entries, + e => e.Level == LogLevel.Warning && e.Message.Contains("already submitted", StringComparison.Ordinal)); + Assert.Contains(first.CARequestID, dedupeLog.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task EnrollCertificateAsync_CalledAgainAfterTheDedupeWindowExpires_CreatesASecondOrder() + { + var clock = new ManualTimeProvider { UtcNow = DateTimeOffset.UtcNow }; + var handler = BuildHandler(); + var client = handler.BuildClient(clock); + await client.AuthenticateAsync(); + + await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + clock.UtcNow = clock.UtcNow.AddMinutes(6); + + await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + Assert.Equal(2, OrderCreationCount(handler)); + } + + [Fact] + public async Task EnrollCertificateAsync_RetriedWhileTheFirstCallIsStillInFlight_OnlyCreatesOneOrder() + { + // The scenario this cache actually exists for: Command's retry doesn't wait for the first + // attempt to finish (or fail) - it can arrive while the original CreateCertificateOrder call + // is still in flight. Holds the order-creation response open with a gate so both calls are + // genuinely concurrent, rather than sequential. + var orderResponseGate = new TaskCompletionSource(); + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .WhenGated(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), orderResponseGate.Task, + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("11111111-1111-1111-1111-111111111111", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var firstCall = client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + // Give the first call a chance to actually reach (and dispatch) the gated POST /order request + // before starting the "retry". + await WaitUntil(() => OrderCreationCount(handler) == 1); + + var secondCall = client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + orderResponseGate.SetResult(); + var results = await Task.WhenAll(firstCall, secondCall); + + Assert.Equal(1, OrderCreationCount(handler)); + Assert.Equal(results[0].CARequestID, results[1].CARequestID); + } + + [Fact] + public async Task EnrollCertificateAsync_RetriedAfterTheWindowNominallyExpiresButWhileStillGenuinelyInFlight_OnlyCreatesOneOrder() + { + // A reservation's nominal window (RecentEnrollmentWindow) is stamped when the call starts, not + // extended while it runs. If the real work takes longer than that window, a retry arriving + // after the nominal expiry - but while the original call is still genuinely in flight - must + // still be deduped, not race the still-running original into creating a second real order. + var clock = new ManualTimeProvider { UtcNow = DateTimeOffset.UtcNow }; + var orderResponseGate = new TaskCompletionSource(); + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .WhenGated(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), orderResponseGate.Task, + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("11111111-1111-1111-1111-111111111111", "CREATED"))); + var client = handler.BuildClient(clock); + await client.AuthenticateAsync(); + + var firstCall = client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + await WaitUntil(() => OrderCreationCount(handler) == 1); + + // Move well past the 5-minute nominal window while the first call is still gated/in-flight. + clock.UtcNow = clock.UtcNow.AddMinutes(6); + + var secondCall = client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + orderResponseGate.SetResult(); + var results = await Task.WhenAll(firstCall, secondCall); + + Assert.Equal(1, OrderCreationCount(handler)); + Assert.Equal(results[0].CARequestID, results[1].CARequestID); + } + + [Fact] + public async Task EnrollCertificateAsync_WhenTheFirstAttemptFails_ARetryGetsAFreshAttemptRatherThanTheCachedFailure() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, + """{"errors":[{"code":"request.genericError","message":"An unexpected error occurred."}]}"""), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("11111111-1111-1111-1111-111111111111", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.EnrollCertificateAsync(SampleCsr.Pem, + "CN=test.mmcertdomain.com", new Dictionary(), "SslDvGeotrust", + new Dictionary(), Config())); + + var retryResult = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + Assert.NotNull(retryResult); + Assert.Equal(2, OrderCreationCount(handler)); + } + + [Fact] + public async Task EnrollCertificateAsync_ManySuccessfulEnrollmentsOverTime_DoesNotAccumulateReservationsUnbounded() + { + // Regression test: _recentEnrollments used to have no eviction path for a *successful* + // enrollment - every unique CSR/subject left a permanent entry (holding the CSR and issued + // cert chain) for the remaining lifetime of the process. After the fix, a stale completed + // reservation is pruned the next time EnrollCertificateAsync runs, so the dictionary tracks + // only "enrollments within the last window", not "enrollments ever performed". + var clock = new ManualTimeProvider { UtcNow = DateTimeOffset.UtcNow }; + var handler = BuildHandler(); + var client = handler.BuildClient(clock); + await client.AuthenticateAsync(); + + await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + Assert.Equal(1, client.RecentEnrollmentsCount); + + clock.UtcNow = clock.UtcNow.AddMinutes(6); + + await client.EnrollCertificateAsync(SampleCsr2.Pem, "CN=other.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + // Only the newest reservation should remain - the first, long-expired-and-completed one must + // have been pruned rather than kept forever. + Assert.Equal(1, client.RecentEnrollmentsCount); + } + + [Fact] + public async Task + EnrollCertificateAsync_WhenTheFirstAttemptFailsWithAnAmbiguousNetworkError_ARetryWithinTheWindowGetsTheSameFailureInsteadOfCreatingASecondOrder() + { + // Regression test: an ambiguous transport-level failure (here, HttpRequestException - the + // exception HttpClient throws for a dropped connection) used to be treated identically to a + // definite rejection, evicting the reservation immediately. A Command retry for the same + // subject/CSR within the window would then find no reservation and create a second, real + // MarkMonitor order - even though the first order might have actually gone through server-side + // before the connection dropped. After the fix, the reservation stays active for an ambiguous + // failure, so the retry is folded into the same failed reservation instead of racing ahead. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .WhenAsync(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + _ => Task.FromException(new HttpRequestException("Simulated connection reset"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.EnrollCertificateAsync(SampleCsr.Pem, + "CN=test.mmcertdomain.com", new Dictionary(), "SslDvGeotrust", + new Dictionary(), Config())); + + await Assert.ThrowsAsync(() => client.EnrollCertificateAsync(SampleCsr.Pem, + "CN=test.mmcertdomain.com", new Dictionary(), "SslDvGeotrust", + new Dictionary(), Config())); + + Assert.Equal(1, OrderCreationCount(handler)); + } + + [Fact] + public async Task + EnrollCertificateAsync_WhenAnEarlierStepFailsWithATransientNetworkError_ARetryGetsAFreshAttemptRatherThanBeingLockedOut() + { + // Regression test: EnrollCertificateAsync used to classify ANY HttpRequestException/ + // TaskCanceledException between winning the dedup reservation and CreateCertificateOrder + // completing as "ambiguous" - even one raised by an earlier step (here, organization + // resolution) that runs strictly before the order-create POST is ever issued and so can never + // have created an order. That kept the reservation active, so a same-second retry got the same + // stale exception replayed at it instead of a fresh attempt - a single transient blip during + // org lookup meant a guaranteed enrollment failure for the rest of the dedupe window, with zero + // risk of a duplicate order to justify it. + // + // Three consecutive failures (not one) are needed here since SendWithRetryAsync (added for + // client-level retry) now retries a transient network error up to 3 times on its own before + // giving up - a single failure would be transparently absorbed by that retry and never reach + // EnrollCertificateAsync's own ambiguity handling at all. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .WhenAsync(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + _ => Task.FromException(new HttpRequestException("Simulated connection reset")), + _ => Task.FromException(new HttpRequestException("Simulated connection reset")), + _ => Task.FromException(new HttpRequestException("Simulated connection reset")), + _ => Task.FromResult(FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact())))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("11111111-1111-1111-1111-111111111111", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.EnrollCertificateAsync(SampleCsr.Pem, + "CN=test.mmcertdomain.com", new Dictionary(), "SslDvGeotrust", + new Dictionary(), Config())); + + var retryResult = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + Assert.NotNull(retryResult); + Assert.Equal(1, OrderCreationCount(handler)); + } + + [Fact] + public async Task + EnrollCertificateAsync_WhenMarkMonitorReturnsSuccessWithAnUnparsableBody_ARetryWithinTheWindowDoesNotCreateASecondOrder() + { + // Regression test: a 2xx order-create response whose body fails to deserialize used to fall + // through CreateCertificateOrder's generic catch as an ordinary exception - not an + // HttpRequestException/TaskCanceledException - so EnrollCertificateAsync's isAmbiguousOutcome + // check treated it as a *definite* failure and evicted the dedup reservation. But MarkMonitor + // had already confirmed (2xx) the order was created - stronger evidence than merely ambiguous + // - so a Command retry finding no reservation would have created a genuine duplicate order. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, "not valid json")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.EnrollCertificateAsync( + SampleCsr.Pem, "CN=test.mmcertdomain.com", new Dictionary(), "SslDvGeotrust", + new Dictionary(), Config())); + + await Assert.ThrowsAsync(() => client.EnrollCertificateAsync( + SampleCsr.Pem, "CN=test.mmcertdomain.com", new Dictionary(), "SslDvGeotrust", + new Dictionary(), Config())); + + Assert.Equal(1, OrderCreationCount(handler)); + } + + [Fact] + public async Task + EnrollCertificateAsync_WhenMarkMonitorReturnsSuccessWithANullBody_ARetryWithinTheWindowDoesNotCreateASecondOrder() + { + // Regression test: a 2xx order-create response whose body is empty/literal "null" deserializes + // to a null OrderContent WITHOUT throwing (Newtonsoft.Json doesn't throw for this case), so it + // slipped past the try/catch that converts unparsable bodies into + // MarkMonitorOrderCreatedButUnparsableException, falling through to `order.Id` and throwing a + // plain NullReferenceException instead - a type isAmbiguousOutcome doesn't recognize, so the + // reservation was evicted and a retry could create a genuine duplicate order. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, "null")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.EnrollCertificateAsync( + SampleCsr.Pem, "CN=test.mmcertdomain.com", new Dictionary(), "SslDvGeotrust", + new Dictionary(), Config())); + + await Assert.ThrowsAsync(() => client.EnrollCertificateAsync( + SampleCsr.Pem, "CN=test.mmcertdomain.com", new Dictionary(), "SslDvGeotrust", + new Dictionary(), Config())); + + Assert.Equal(1, OrderCreationCount(handler)); + } + + [Fact] + public async Task + EnrollCertificateAsync_WhenTheTokenExpiresDuringOrgResolutionAndTheNestedReAuthFails_EvictsTheReservation() + { + // Regression test: reachedCreateOrderCall used to be set immediately before calling + // CreateCertificateOrder, but CreateCertificateOrder itself runs its own EnsureAuthenticatedAsync + // check before ever sending the order-create POST. If the token expired again during org/ + // contact/group resolution (a slow lookup against a large account), that nested re-auth + // attempt's own failure was misclassified as "ambiguous, order might exist" even though the + // order-create POST was never reached - keeping a dedup reservation active for the rest of the + // window and locking out a retry that would otherwise succeed immediately. + var clock = new ManualTimeProvider { UtcNow = DateTimeOffset.UtcNow }; + var handler = new FakeHttpMessageHandler() + .WhenAsync(req => FakeHttpMessageHandler.Is(req, "POST", "/auth/v1/auth/authenticate"), + _ => Task.FromResult(FakeHttpMessageHandler.Json(HttpStatusCode.OK, + """{"token":"fake-token","expiresIn":60}""")), + _ => Task.FromException(new HttpRequestException("Simulated connection reset"))) + .WhenAsync(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), _ => + { + clock.UtcNow = clock.UtcNow.AddSeconds(100); // Expire the token while "resolving" the org. + return Task.FromResult(FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))); + }); + var client = handler.BuildClient(clock); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.EnrollCertificateAsync(SampleCsr.Pem, + "CN=test.mmcertdomain.com", new Dictionary(), "SslDvGeotrust", + new Dictionary(), Config())); + + // The failure happened before the order-create POST was ever reached, so the dedup reservation + // must have been evicted immediately, not kept "ambiguous" for the rest of the window. + Assert.Equal(0, client.RecentEnrollmentsCount); + } + + private static async Task WaitUntil(Func condition) + { + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!condition()) + { + if (DateTime.UtcNow > deadline) throw new TimeoutException("Condition was never met."); + await Task.Delay(5); + } + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientEnrollLoggingTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientEnrollLoggingTests.cs new file mode 100644 index 0000000..1e88f2d --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientEnrollLoggingTests.cs @@ -0,0 +1,161 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +[Collection(LogHandlerFactoryCollection.Name)] +public class MarkMonitorClientEnrollLoggingTests +{ + [Fact] + public async Task EnrollCertificateAsync_WithNoResolvableContactOrGroup_StillSucceeds() + { + // Logging the resolved ContactId/GroupId against the new order's CARequestID must not throw + // when either is null (no contact on the org, no MarkmonitorGroup param supplied). + const string orgWithNoContacts = """ + {"id": "11111111-1111-1111-1111-111111111111", "name": "Test Org", + "provider": "DIGICERT", "providerId": 1, "contacts": []} + """; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrgs.OrgsListResponse(orgWithNoContacts))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("22222222-2222-2222-2222-222222222222", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var config = SampleConfig.Default(); + + var result = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), config); + + Assert.NotNull(result); + Assert.Equal("22222222-2222-2222-2222-222222222222", result.CARequestID); + } + + [Fact] + public async Task EnrollCertificateAsync_WithCrLfInGroupAndDcvMethodParams_SanitizesThemInLogOutput() + { + // Regression test (CWE-117), extending the Subject/CommonName sanitization to the other + // requester-controlled, free-text enrollment template parameters (MarkmonitorGroup, DCVMethod, + // Comments, Locale, Provider) - a full-review round found the first fix only covered + // Subject/CommonName, leaving the same forged-log-line vector open through these fields. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + + const string maliciousGroup = "no-such-group\r\n2026-08-10 09:00:00 [INF] FAKE forged log line"; + const string maliciousDcvMethod = "BOGUS\r\n2026-08-10 09:00:00 [INF] FAKE forged log line"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/auth/v1/group"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, """{"groups":[],"page":{"totalPages":1}}""")) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("22222222-2222-2222-2222-222222222222", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var productParams = new Dictionary + { + [MarkMonitorCAPluginConfig.EnrollmentConfigConstants.MarkmonitorGroup] = maliciousGroup, + [MarkMonitorCAPluginConfig.EnrollmentConfigConstants.DCVMethod] = maliciousDcvMethod + }; + + var result = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", productParams, SampleConfig.Default()); + + Assert.NotNull(result); + Assert.DoesNotContain(capturingFactory.Messages, m => m.Contains("\r\n", StringComparison.Ordinal)); + Assert.Contains(capturingFactory.Messages, + m => m.Contains("could not be resolved", StringComparison.OrdinalIgnoreCase) && + m.Contains("\\r\\n", StringComparison.Ordinal)); + Assert.Contains(capturingFactory.Messages, + m => m.Contains("Invalid DCVMethod", StringComparison.OrdinalIgnoreCase) && + m.Contains("\\r\\n", StringComparison.Ordinal)); + } + + [Fact] + public async Task EnrollCertificateAsync_ResolvingGroupByName_RequestsAPageSizeLargeEnoughToAvoidOneRoundTripPerMatch() + { + // Regression test: ResolveGroupIdAsync used to request the server default page size (no + // `size=` param at all) instead of the larger page size ResolveOrganizationIdAsync already + // uses for the identical fuzzy-match, filter-by-exact-name pattern - risking one sequential + // HTTP round-trip per fuzzy-matching group instead of one round-trip total. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/auth/v1/group"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, """{"groups":[],"page":{"totalPages":1}}""")) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("33333333-3333-3333-3333-333333333333", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var productParams = new Dictionary + { + [MarkMonitorCAPluginConfig.EnrollmentConfigConstants.MarkmonitorGroup] = "some-group" + }; + + await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", productParams, SampleConfig.Default()); + + var groupRequest = Assert.Single(handler.Requests, req => FakeHttpMessageHandler.Is(req, "GET", "/auth/v1/group")); + Assert.Contains("size=100", groupRequest.RequestUri!.Query); + } + + [Fact] + public async Task EnrollCertificateAsync_CalledTwiceWithTheSameGroupName_OnlyResolvesTheGroupOnce() + { + // Regression test: resolving a named MarkmonitorGroup used to make a fresh MarkMonitor API + // call on every single enrollment, even though a resolved group name never maps to a + // different GUID later - a real cost at steady enrollment volume. A resolved group GUID is + // now cached for this client's lifetime. + const string groupId = "44444444-4444-4444-4444-444444444444"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/auth/v1/group"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + $$"""{"groups":[{"id":"{{groupId}}","name":"some-group"}],"page":{"totalPages": 1} }""")) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("55555555-5555-5555-5555-555555555555", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var productParams = new Dictionary + { + [MarkMonitorCAPluginConfig.EnrollmentConfigConstants.MarkmonitorGroup] = "some-group" + }; + + await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test1.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", productParams, SampleConfig.Default()); + await client.EnrollCertificateAsync(SampleCsr2.Pem, "CN=test2.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", productParams, SampleConfig.Default()); + + Assert.Single(handler.Requests, req => FakeHttpMessageHandler.Is(req, "GET", "/auth/v1/group")); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientEnrollTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientEnrollTests.cs new file mode 100644 index 0000000..4c8f550 --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientEnrollTests.cs @@ -0,0 +1,169 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +public class MarkMonitorClientEnrollTests +{ + private static MarkMonitorConfig Config() => new() + { + BaseUrl = "https://api.markmonitor.test", + ApiKey = "key", + ApiUsername = "user", + ApiPassword = "pass", + OrgName = "Test Org", + Enabled = true, + PickupRetries = 0 // disable post-submit polling - not what these tests exercise + }; + + [Fact] + public async Task EnrollCertificateAsync_WhenCreateOrderReturnsValidationError_ThrowsWithRealDetail() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.BadRequest, + """{"validations":[{"field":"cert.csr","code":"field.invalidFormat","message":"The CSR format is invalid."}]}""")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var ex = await Assert.ThrowsAsync(() => + client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config())); + + Assert.Contains("The CSR format is invalid", ex.Message); + Assert.Contains("cert.csr", ex.Message); + } + + [Fact] + public async Task EnrollCertificateAsync_WhenOrgNameIsAGuid_FetchesTheOrganizationDirectlyById() + { + // The OrgId CA connection setting is documented as accepting either a friendly name or a + // GUID. Before this fix, a GUID was always passed as a *name* search filter, which would + // never match a real org (org names aren't GUIDs) and enrollment would fail with + // "Organization ID not found". + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/organization/{SampleOrgs.DefaultOrgId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrgs.OrgWithContact())) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("33333333-3333-3333-3333-333333333333", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var config = Config(); + config.OrgName = SampleOrgs.DefaultOrgId; + + var result = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), config); + + Assert.NotNull(result); + Assert.DoesNotContain(handler.Requests, + req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization?")); + } + + [Fact] + public async Task EnrollCertificateAsync_WhenOrgNameIsAFriendlyName_SearchesOrganizationsByName() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("44444444-4444-4444-4444-444444444444", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + Assert.NotNull(result); + Assert.Contains(handler.Requests, req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization?")); + } + + [Fact] + public async Task EnrollCertificateAsync_WhenEccCsrUsesExplicitCurveParameters_ThrowsWithoutSubmittingTheOrder() + { + // MarkMonitor silently fails an order for an ECC CSR whose key uses explicit curve + // parameters instead of a named-curve OID reference (confirmed against a live sandbox: the + // order reaches DIGI_FAILED in under a second, with no reason surfaced anywhere in the API). + // Reject it at enrollment time with an actionable error instead. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var ex = await Assert.ThrowsAsync(() => + client.EnrollCertificateAsync(SampleEccCsrs.ExplicitCurvePem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config())); + + Assert.Contains("named curve", ex.Message); + Assert.DoesNotContain(handler.Requests, req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order")); + } + + [Fact] + public async Task EnrollCertificateAsync_WhenEccCsrUsesANamedCurve_EnrollsSuccessfully() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("55555555-5555-5555-5555-555555555555", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.EnrollCertificateAsync(SampleEccCsrs.NamedCurvePem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + Assert.NotNull(result); + Assert.Contains(handler.Requests, req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order")); + } + + [Fact] + public async Task EnrollCertificateAsync_WithANumericOrderTypeForAnUndefinedEnumValue_ThrowsWithoutSubmittingTheOrder() + { + // Regression test: Enum.Parse alone "succeeds" for any numeric string that + // fits the underlying int type, even with no member defined for that value (CertOrderTypes + // has 12 members, values 0-11) - Enum.IsDefined is the check that actually enforces membership. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var ex = await Assert.ThrowsAsync(() => + client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "20", new Dictionary(), Config())); + + Assert.Contains("20", ex.Message); + Assert.DoesNotContain(handler.Requests, req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order")); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientErrorMessageRedactionTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientErrorMessageRedactionTests.cs new file mode 100644 index 0000000..283b568 --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientErrorMessageRedactionTests.cs @@ -0,0 +1,68 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +public class MarkMonitorClientErrorMessageRedactionTests +{ + [Fact] + public async Task UnrecognizedErrorShape_IsTruncatedRatherThanDumpedVerbatim() + { + // Order/contact payloads can carry customer PII (name, email). An unrecognized MarkMonitor + // error shape used to get dumped whole into the exception message / error-level logs. + var contactPii = "Jane Doe, jane.doe@example.com, " + new string('x', 300); + var unrecognizedBody = "{\"someUnexpectedField\": \"" + contactPii + "\"}"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, unrecognizedBody)); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var ex = await Assert.ThrowsAsync(() => client.ListOrganizationsAsync()); + + Assert.True(ex.Message.Length < unrecognizedBody.Length); + Assert.Contains("(truncated)", ex.Message); + Assert.DoesNotContain(new string('x', 300), ex.Message); + } + + [Fact] + public async Task ValidationErrorMessageContainingCrLf_IsSanitizedInTheThrownException() + { + // Regression test (CWE-117): if MarkMonitor's own validation response ever echoes back a + // requester-controlled value (e.g. a CSR-derived field) verbatim in its "message" text, an + // embedded CR/LF must not reach this component's logs unescaped - the same guarantee already + // applied to every request-side field must hold for this response-derived path too. + const string maliciousMessage = + "cert.csr is invalid\r\n2026-08-10 09:00:00 [INF] Enrollment completed successfully for subject: CN=admin.internal"; + var validationBody = $$""" + {"validations":[{"field":"cert.csr","code":"field.invalidFormat","message":"{{maliciousMessage.Replace("\r\n", "\\r\\n")}}"}]} + """; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.BadRequest, validationBody)); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var ex = await Assert.ThrowsAsync(() => client.ListOrganizationsAsync()); + + Assert.DoesNotContain("\r\n", ex.Message, StringComparison.Ordinal); + Assert.Contains("\\r\\n", ex.Message, StringComparison.Ordinal); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientErrorPropagationTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientErrorPropagationTests.cs new file mode 100644 index 0000000..77e7e53 --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientErrorPropagationTests.cs @@ -0,0 +1,123 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Concurrent; +using System.Net; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +[Collection(LogHandlerFactoryCollection.Name)] +public class MarkMonitorClientErrorPropagationTests +{ + [Fact] + public async Task ListOrganizationsAsync_WhenTheApiErrors_ThrowsInsteadOfReturningNull() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, + """{"errors":[{"code":"request.genericError","message":"An unexpected error occurred."}]}""")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var ex = await Assert.ThrowsAsync(() => client.ListOrganizationsAsync()); + + Assert.Contains("An unexpected error occurred", ex.Message); + } + + [Fact] + public async Task ListCertificateOrdersAsync_WhenTheApiErrors_ThrowsInsteadOfReturningNull() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, + """{"errors":[{"code":"request.genericError","message":"An unexpected error occurred."}]}""")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var ex = await Assert.ThrowsAsync(() => client.ListCertificateOrdersAsync(0, "", "", 100)); + + Assert.Contains("An unexpected error occurred", ex.Message); + } + + [Fact] + public async Task GetCertificateInventoryAsync_WhenListingOrdersFails_PropagatesTheRealErrorAndCompletesTheBuffer() + { + // Before this fix, a failure here was swallowed and GetCertificateInventoryAsync returned 0 + // - Synchronize() would report "0 certificates synced" as if the sync had genuinely found + // nothing, rather than failing loudly on a real API error. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, + """{"errors":[{"code":"request.genericError","message":"An unexpected error occurred."}]}""")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var buffer = new BlockingCollection(); + + await Assert.ThrowsAsync(() => + client.GetCertificateInventoryAsync("", "", 100, buffer, CancellationToken.None)); + + // The buffer must still be marked complete (in a `finally`) even on failure, or a consumer + // blocked on GetConsumingEnumerable() would hang forever. + Assert.True(buffer.IsAddingCompleted); + } + + [Fact] + public async Task ListGroupsAsync_WhenTheApiErrors_StillReturnsNullRatherThanThrowing() + { + // Unlike the org/order listing methods above, group resolution is an optional, best-effort + // lookup - EnrollCertificateAsync deliberately proceeds without a group when this fails, so + // this one method is expected to keep swallowing rather than throwing. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/auth/v1/group"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, + """{"errors":[{"code":"request.genericError","message":"An unexpected error occurred."}]}""")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.ListGroupsAsync(0, 0, "Engineering"); + + Assert.Null(result); + } + + [Fact] + public async Task SendAndLogAsync_WhenTheUnderlyingCallFailsBeforeAnyResponse_StillLogsMethodAndUrl() + { + // Regression test: SendAndLogAsync only logged method/URL/status/elapsed-time after send() + // returned successfully - a transport-level failure (timeout, DNS failure, connection refused/ + // reset, TLS failure) never produces a response at all, so that log line never ran, leaving + // only whichever generic, URL-less message an enclosing caller's own catch happened to log. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .WhenAsync(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + _ => Task.FromException(new HttpRequestException("Simulated connection reset"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.ListOrganizationsAsync()); + + Assert.Contains(capturingFactory.Messages, + m => m.Contains("GET", StringComparison.Ordinal) && + m.Contains("/certs/v1/organization", StringComparison.Ordinal) && + m.Contains("failed", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientFetchOrderAuthTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientFetchOrderAuthTests.cs new file mode 100644 index 0000000..d8fc3e5 --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientFetchOrderAuthTests.cs @@ -0,0 +1,96 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +/// +/// Regression tests for GitHub issue #8: FetchOrderAsync (the private helper behind +/// GetSingleOrderAsync/RevokeCertificateAsync) used to re-set the shared HttpClient's +/// Authorization header itself, directly from _bearerToken and with no locking - bypassing the +/// _authLock discipline that EnsureAuthenticatedAsync/AuthenticateAsync rely on to keep concurrent +/// callers from racing writes to that shared header. The fix removes the redundant, unguarded +/// re-set and relies solely on EnsureAuthenticatedAsync (called immediately above it) to have +/// already established the header under the lock. +/// +public class MarkMonitorClientFetchOrderAuthTests +{ + private const string OrderId = "99999999-9999-9999-9999-999999999999"; + + private static int AuthCallCount(FakeHttpMessageHandler handler) => + handler.Requests.Count(r => FakeHttpMessageHandler.Is(r, "POST", "/auth/v1/auth/authenticate")); + + private static IEnumerable OrderRequests(FakeHttpMessageHandler handler) => + handler.Requests.Where(r => FakeHttpMessageHandler.Is(r, "GET", $"/certs/v1/order/{OrderId}")); + + [Fact] + public async Task GetSingleOrderAsync_SendsTheCurrentBearerTokenOnTheOrderRequest() + { + // Simple regression check: with the manual (unguarded) header re-set removed from + // FetchOrderAsync, the request must still carry the Authorization header that + // EnsureAuthenticatedAsync/AuthenticateAsync established under the lock. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth(token: "regression-token") + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{OrderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(OrderId, "DIGI_ISSUED"))); + var client = handler.BuildClient(); + + await client.GetSingleOrderAsync(OrderId); + + var orderRequest = Assert.Single(OrderRequests(handler)); + Assert.NotNull(orderRequest.Headers.Authorization); + Assert.Equal("Bearer", orderRequest.Headers.Authorization!.Scheme); + Assert.Equal("regression-token", orderRequest.Headers.Authorization!.Parameter); + } + + [Fact] + public async Task GetSingleOrderAsync_CalledConcurrentlyWithAnExpiredToken_OnlyAuthenticatesOnceAndSendsTheRefreshedTokenOnBothRequests() + { + // Before the fix, FetchOrderAsync set the shared HttpClient's Authorization header itself, + // unguarded by _authLock, immediately after calling EnsureAuthenticatedAsync. Two concurrent + // FetchOrderAsync calls racing a concurrent re-authentication could interleave writes to + // that shared header. Gate the auth response so both calls are genuinely in flight together + // (mirrors EnsureAuthenticatedAsync_CalledConcurrentlyWithAnExpiredToken_OnlyAuthenticatesOnce + // in MarkMonitorClientAuthLifecycleTests, applied to the FetchOrderAsync code path). + var clock = new ManualTimeProvider { UtcNow = DateTimeOffset.UtcNow }; + var authGate = new TaskCompletionSource(); + var handler = new FakeHttpMessageHandler() + .WhenGated(req => FakeHttpMessageHandler.Is(req, "POST", "/auth/v1/auth/authenticate"), authGate.Task, + FakeHttpMessageHandler.Json(HttpStatusCode.OK, """{"token":"refreshed-token","expiresIn":3600}""")) + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{OrderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(OrderId, "DIGI_ISSUED"))); + var client = handler.BuildClient(clock); + + // No prior AuthenticateAsync call, so both concurrent calls see an expired/missing token + // and race into EnsureAuthenticatedAsync at the same time. + var firstCall = client.GetSingleOrderAsync(OrderId); + var secondCall = client.GetSingleOrderAsync(OrderId); + authGate.SetResult(); + await Task.WhenAll(firstCall, secondCall); + + Assert.Equal(1, AuthCallCount(handler)); + + var orderRequests = OrderRequests(handler).ToList(); + Assert.Equal(2, orderRequests.Count); + Assert.All(orderRequests, req => + { + Assert.NotNull(req.Headers.Authorization); + Assert.Equal("Bearer", req.Headers.Authorization!.Scheme); + Assert.Equal("refreshed-token", req.Headers.Authorization!.Parameter); + }); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientGetSingleOrderTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientGetSingleOrderTests.cs new file mode 100644 index 0000000..bd0a660 --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientGetSingleOrderTests.cs @@ -0,0 +1,126 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; +using Keyfactor.PKI.Enums.EJBCA; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +public class MarkMonitorClientGetSingleOrderTests +{ + [Fact] + public async Task GetSingleOrderAsync_ForIssuedOrder_ReturnsNonNullResultWithoutThrowing() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order/55555555-5555-5555-5555-555555555555"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert("55555555-5555-5555-5555-555555555555", "DIGI_ISSUED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.GetSingleOrderAsync("55555555-5555-5555-5555-555555555555"); + + Assert.NotNull(result); + Assert.Equal("55555555-5555-5555-5555-555555555555", result.CARequestID); + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + Assert.Null(result.RevocationDate); + } + + [Fact] + public async Task GetSingleOrderAsync_ForRevokedOrder_SetsRevocationDateFromDateValidUntil() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order/66666666-6666-6666-6666-666666666666"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert("66666666-6666-6666-6666-666666666666", "DIGI_REVOKED", "REVOKED", "2026-03-01T00:00:00Z"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.GetSingleOrderAsync("66666666-6666-6666-6666-666666666666"); + + Assert.NotNull(result); + Assert.Equal((int)EndEntityStatus.REVOKED, result.Status); + Assert.Equal(new DateTime(2026, 3, 1), result.RevocationDate); + } + + [Fact] + public async Task GetSingleOrderAsync_WhenTheApiErrors_ThrowsInsteadOfReturningNull() + { + // Before this fix, any failure here (auth expiry, network blip, malformed body) was + // logged and then swallowed to null - GetSingleRecord would log a false "retrieved + // successfully" line right after the real error, and Command would see "not found" + // instead of a real, actionable error. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order/77777777-7777-7777-7777-777777777777"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, + """{"errors":[{"code":"request.genericError","message":"An unexpected error occurred."}]}""")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var ex = await Assert.ThrowsAsync( + () => client.GetSingleOrderAsync("77777777-7777-7777-7777-777777777777")); + + Assert.Contains("An unexpected error occurred", ex.Message); + } + + [Fact] + public async Task GetSingleOrderAsync_ForOrderWithNoCertYet_ReturnsAResultWithoutThrowing() + { + // Cert can be null for an order that hasn't progressed far enough yet (e.g. CREATED) - same + // class of gap already fixed in GetCertificateInventoryAsync. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order/88888888-8888-8888-8888-888888888888"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithNullCert("88888888-8888-8888-8888-888888888888", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.GetSingleOrderAsync("88888888-8888-8888-8888-888888888888"); + + Assert.NotNull(result); + Assert.Equal("88888888-8888-8888-8888-888888888888", result.CARequestID); + Assert.Null(result.Certificate); + Assert.Null(result.RevocationDate); + } + + [Fact] + public async Task GetSingleOrderAsync_CalledRepeatedly_DoesNotAccumulateDuplicateAcceptHeaders() + { + // Regression test: FetchOrderAsync used to Add() an "application/json" Accept header on the + // shared, cached HttpClient on every call with no preceding Remove() - since Accept is a + // collection, not a single-value property, that appended a fresh duplicate entry per call, + // unboundedly growing the header list (sent on every subsequent request) for the life of the + // cached client. The Accept header is now set once, in the constructor. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order/99999999-9999-9999-9999-999999999999"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert("99999999-9999-9999-9999-999999999999", "DIGI_ISSUED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + for (var i = 0; i < 3; i++) + await client.GetSingleOrderAsync("99999999-9999-9999-9999-999999999999"); + + var orderRequests = handler.Requests.Where(r => + FakeHttpMessageHandler.Is(r, "GET", "/certs/v1/order/99999999-9999-9999-9999-999999999999")); + Assert.All(orderRequests, r => Assert.Single(r.Headers.Accept)); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientInventoryTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientInventoryTests.cs new file mode 100644 index 0000000..b904d2f --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientInventoryTests.cs @@ -0,0 +1,126 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Concurrent; +using System.Net; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +public class MarkMonitorClientInventoryTests +{ + [Fact] + public async Task GetCertificateInventoryAsync_WhenOneOrderHasNoCertYet_SkipsItButKeepsTheRest() + { + // A page containing one order with cert:null (plausible for a CREATED/DIGI_NEEDS_CSR order) + // used to NRE partway through the foreach, get swallowed by the outer catch, and silently + // drop every remaining order on that page - Synchronize() would report success while quietly + // losing certificates from Command's inventory. + var ordersJson = string.Join(",", new[] + { + SampleOrders.OrderWithNullCert("99999999-9999-9999-9999-999999999999", "CREATED"), + SampleOrders.OrderWithCert("77777777-7777-7777-7777-777777777777", "DIGI_ISSUED"), + SampleOrders.OrderWithCert("88888888-8888-8888-8888-888888888888", "DIGI_ISSUED") + }); + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrdersPage(ordersJson))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var buffer = new BlockingCollection(); + + var count = await client.GetCertificateInventoryAsync("", "", 100, buffer, CancellationToken.None); + + Assert.Equal(2, count); + var collected = buffer.ToList(); + Assert.Equal(2, collected.Count); + Assert.DoesNotContain(collected, c => c.CARequestID == "99999999-9999-9999-9999-999999999999"); + Assert.Contains(collected, c => c.CARequestID == "77777777-7777-7777-7777-777777777777"); + Assert.Contains(collected, c => c.CARequestID == "88888888-8888-8888-8888-888888888888"); + } + + [Fact] + public async Task GetCertificateInventoryAsync_CancelledWhilePagingThroughOrders_StopsRatherThanFetchingEveryRemainingPage() + { + // Regression test: the cancelToken passed in was never threaded into the paginated + // ListCertificateOrdersAsync HTTP fetch loop - the only place it was ever consulted was + // certificatesBuffer.Add(..., cancelToken), which only runs after every page has already been + // downloaded. A Command-initiated sync cancellation issued while still paging couldn't take + // effect until the entire (potentially very large) order list had already been fetched. + var page2Gate = new TaskCompletionSource(); + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrdersPage(SampleOrders.OrderWithCert( + "77777777-7777-7777-7777-777777777777", "DIGI_ISSUED"), totalPages: 2))) + .WhenGated(req => FakeHttpMessageHandler.Is(req, "GET", "page=1"), page2Gate.Task, + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrdersPage(SampleOrders.OrderWithCert( + "88888888-8888-8888-8888-888888888888", "DIGI_ISSUED"), totalPages: 2))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var buffer = new BlockingCollection(); + using var cts = new CancellationTokenSource(); + + var syncTask = client.GetCertificateInventoryAsync("", "", 100, buffer, cts.Token); + await Task.Delay(50); // Let the first page complete and the second page's gated fetch start. + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => syncTask); + // The gated (never-released) second page must not have been allowed to complete the sync. + Assert.False(page2Gate.Task.IsCompleted); + } + + [Fact] + public async Task GetCertificateInventoryAsync_WithMultiplePages_AddsEachPageToTheBufferAsItArrivesRatherThanWaitingForAllPages() + { + // Regression test: ListCertificateOrdersAsync used to accumulate every page's orders into one + // in-memory list before ever returning, so GetCertificateInventoryAsync's buffer-writing loop + // saw zero throughput until the entire (potentially huge) order history had downloaded. Page 1 + // must reach the buffer before page 2's gated fetch is ever released. + var page2Gate = new TaskCompletionSource(); + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrdersPage(SampleOrders.OrderWithCert( + "77777777-7777-7777-7777-777777777777", "DIGI_ISSUED"), totalPages: 2))) + .WhenGated(req => FakeHttpMessageHandler.Is(req, "GET", "page=1"), page2Gate.Task, + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrdersPage(SampleOrders.OrderWithCert( + "88888888-8888-8888-8888-888888888888", "DIGI_ISSUED"), totalPages: 2))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var buffer = new BlockingCollection(); + + var syncTask = client.GetCertificateInventoryAsync("", "", 100, buffer, CancellationToken.None); + + // Page 1's certificate must already be in the buffer while page 2 is still gated/pending - + // proving the buffer is fed page-by-page, not only after the whole order history downloads. + var firstFromBuffer = buffer.Take(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token); + Assert.Equal("77777777-7777-7777-7777-777777777777", firstFromBuffer.CARequestID); + Assert.False(page2Gate.Task.IsCompleted); + + page2Gate.SetResult(); + var count = await syncTask; + + Assert.Equal(2, count); + var second = buffer.Take(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token); + Assert.Equal("88888888-8888-8888-8888-888888888888", second.CARequestID); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientOrderIdValidationTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientOrderIdValidationTests.cs new file mode 100644 index 0000000..0ee4e22 --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientOrderIdValidationTests.cs @@ -0,0 +1,102 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +public class MarkMonitorClientOrderIdValidationTests +{ + private const string NotAGuid = "../../etc/passwd"; + + [Fact] + public async Task GetSingleOrderAsync_WithNonGuidOrderId_ThrowsWithoutMakingARequest() + { + var handler = new FakeHttpMessageHandler().WithSuccessfulAuth(); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.GetSingleOrderAsync(NotAGuid)); + + Assert.DoesNotContain(handler.Requests, r => FakeHttpMessageHandler.Is(r, "GET", "/order/")); + } + + [Fact] + public async Task CancelCertificateAsync_WithNonGuidOrderId_ThrowsWithoutMakingARequest() + { + var handler = new FakeHttpMessageHandler().WithSuccessfulAuth(); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.CancelCertificateAsync(NotAGuid)); + + Assert.DoesNotContain(handler.Requests, r => FakeHttpMessageHandler.Is(r, "PATCH", "/cancel")); + } + + [Fact] + public async Task RevokeCertificateAsync_WithNonGuidOrderId_ThrowsWithoutMakingARequest() + { + var handler = new FakeHttpMessageHandler().WithSuccessfulAuth(); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.RevokeCertificateAsync(NotAGuid)); + + Assert.DoesNotContain(handler.Requests, r => FakeHttpMessageHandler.Is(r, "PATCH", "/revoke")); + } + + [Fact] + public async Task GetOrganizationAsync_WithNonGuidOrgId_ThrowsWithoutMakingARequest() + { + // GetOrganizationAsync's URL interpolates orgId directly (/certs/v1/organization/{orgId}), + // same class of injection risk ValidateGuidFormat already guards against for order IDs. + // + // Asserting the specific exception type matters here: dot-segments in NotAGuid get + // normalized out of the request URI by Uri/HttpClient regardless of whether validation ran, + // so a looser assertion (e.g. "no organization/ in the URI") would pass even if + // ValidateGuidFormat were deleted - only asserting ArgumentException (vs. whatever the fake + // handler's "no route registered" exception would surface as) actually proves validation + // ran before any request was attempted. + var handler = new FakeHttpMessageHandler().WithSuccessfulAuth(); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.GetOrganizationAsync(NotAGuid)); + + Assert.DoesNotContain(handler.Requests, r => FakeHttpMessageHandler.Is(r, "GET", "/organization/")); + } + + [Fact] + public async Task GetOrganizationAsync_WhenTheApiErrors_ThrowsInsteadOfReturningNull() + { + // Before this fix, any failure here (auth expiry, network blip, malformed body) was logged + // and then swallowed to null - a caller resolving a GUID-configured OrgId (EnrollCertificateAsync's + // ResolveOrganizationAsync) would report a misleading "Organization ID not found" instead of + // the real error - same class of gap already fixed in GetSingleOrderAsync. + const string orgId = "11111111-1111-1111-1111-111111111111"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/organization/{orgId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, + """{"errors":[{"code":"request.genericError","message":"An unexpected error occurred."}]}""")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var ex = await Assert.ThrowsAsync(() => client.GetOrganizationAsync(orgId)); + + Assert.Contains("An unexpected error occurred", ex.Message); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientOrgResolutionTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientOrgResolutionTests.cs new file mode 100644 index 0000000..77dbb77 --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientOrgResolutionTests.cs @@ -0,0 +1,167 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; +using Newtonsoft.Json.Linq; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +/// +/// Regression coverage for GitHub issue #9: ResolveOrganizationAsync/ResolveOrganizationIdAsync used +/// to take the *first* org MarkMonitor's name search returned without checking it was an exact match. +/// If MarkMonitor's /certs/v1/organization name filter does substring/fuzzy matching, a configured org +/// name that's a substring of another org's name (e.g. "Acme" vs "Acme Corp Europe") could silently +/// resolve to the wrong organization - undermining the cross-org ownership check in +/// RevokeCertificateAsync. Both resolvers must filter to an exact (case-insensitive) name match. +/// +public class MarkMonitorClientOrgResolutionTests +{ + private const string ExactOrgId = "11111111-1111-1111-1111-111111111111"; + private const string SubstringOrgId = "33333333-3333-3333-3333-333333333333"; + + [Fact] + public async Task EnrollCertificateAsync_WhenSearchReturnsASubstringFalsePositive_ResolvesTheExactMatchOnly() + { + // MarkMonitor's search for "Acme" returns both the exact org and an unrelated org whose name + // merely contains "Acme" - and, to prove the fix filters by exact match rather than list + // position, the substring false positive is returned *first*. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse( + SampleOrgs.OrgWithContact(SubstringOrgId, "Acme Corp Europe"), + SampleOrgs.OrgWithContact(ExactOrgId, "Acme")))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("44444444-4444-4444-4444-444444444444", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var config = SampleConfig.Default("Acme"); + + var result = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), config); + + Assert.NotNull(result); + var orderRequest = Assert.Single(handler.Requests, req => FakeHttpMessageHandler.Is(req, "POST", "/order")); + var body = await orderRequest.Content!.ReadAsStringAsync(); + Assert.Equal(ExactOrgId, JObject.Parse(body)["organizationId"]!.Value(), + ignoreCase: true); + } + + [Fact] + public async Task EnrollCertificateAsync_WhenNoExactNameMatchExists_ThrowsWithoutSubmittingTheOrder() + { + // Only the substring false positive is returned - there is no org actually named "Acme" - so + // resolution must fail the same way it always has for "no match", not fall back to the + // unrelated org. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact(SubstringOrgId, "Acme Corp Europe")))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var config = SampleConfig.Default("Acme"); + + var ex = await Assert.ThrowsAsync(() => + client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), config)); + + Assert.Contains("not found", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(handler.Requests, req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order")); + } + + [Fact] + public async Task RevokeCertificateAsync_WhenSearchReturnsASubstringFalsePositive_ResolvesTheExactMatchOnly() + { + // The order actually belongs to the exact-match org. If the buggy "take the first result" + // behavior were still present, list order (substring false positive first) would resolve the + // configured "Acme" to the wrong org and this legitimate revoke would be wrongly refused. + const string orderId = "55555555-5555-5555-5555-555555555555"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse( + SampleOrgs.OrgWithContact(SubstringOrgId, "Acme Corp Europe"), + SampleOrgs.OrgWithContact(ExactOrgId, "Acme")))) + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{orderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert(orderId, "DIGI_ISSUED", organizationId: ExactOrgId))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{orderId}/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.RevokeCertificateAsync(orderId, "Acme"); + + Assert.True(result); + } + + [Fact] + public async Task RevokeCertificateAsync_WhenNoExactNameMatchExists_ThrowsWithoutRevoking() + { + // Only the substring false positive is returned - there is no org actually named "Acme" - so + // resolution must come back empty (same "not found" contract as before) rather than falling + // back to the unrelated org and letting an unauthorized revoke through. + const string orderId = "55555555-5555-5555-5555-555555555555"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact(SubstringOrgId, "Acme Corp Europe")))) + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{orderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert(orderId, "DIGI_ISSUED", organizationId: SubstringOrgId))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{orderId}/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.RevokeCertificateAsync(orderId, "Acme")); + + Assert.DoesNotContain(handler.Requests, r => FakeHttpMessageHandler.Is(r, "PATCH", "/revoke")); + } + + [Fact] + public async Task EnrollCertificateAsync_ResolvingOrgByName_RequestsAPageSizeLargeEnoughToAvoidOneRoundTripPerMatch() + { + // Regression test: ResolveOrganizationAsync/ResolveOrganizationIdAsync used to hard-code a + // page size of 1 for the org-name search. Since MarkMonitor's name filter can return several + // fuzzy matches for one configured name, that forced one sequential HTTP round-trip per + // matching org just to page through them all before the exact-match filter (above) ever ran. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("44444444-4444-4444-4444-444444444444", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), + SampleConfig.Default()); + + var orgRequest = Assert.Single(handler.Requests, + req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization")); + var query = orgRequest.RequestUri!.Query; + Assert.Contains("size=100", query); + Assert.DoesNotContain("size=1&", query); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientPickupPollingTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientPickupPollingTests.cs new file mode 100644 index 0000000..c01bf1d --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientPickupPollingTests.cs @@ -0,0 +1,259 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; +using Keyfactor.PKI.Enums.EJBCA; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +/// +/// Coverage for EnrollCertificateAsync's post-submit issuance polling - MarkMonitor always returns a +/// freshly-created order pending (never issued synchronously), so a fast-DCV product would otherwise +/// always report pending status back to Command even when it's about to issue within seconds. +/// +public class MarkMonitorClientPickupPollingTests +{ + private const string OrderId = "11111111-1111-1111-1111-111111111111"; + + private static MarkMonitorConfig Config(int pickupRetries = 5, int pickupDelaySeconds = 10) + { + var config = SampleConfig.Default(); + config.PickupRetries = pickupRetries; + config.PickupDelaySeconds = pickupDelaySeconds; + return config; + } + + private static int OrderFetchCount(FakeHttpMessageHandler handler) => + handler.Requests.Count(r => FakeHttpMessageHandler.Is(r, "GET", $"/certs/v1/order/{OrderId}")); + + private static FakeHttpMessageHandler BuildHandlerWithOrgAndCreate() => + new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, SampleOrders.OrderWithCert(OrderId, "CREATED"))); + + [Fact] + public async Task EnrollCertificateAsync_WhenTheOrderIssuesOnASubsequentPoll_ReturnsGeneratedWithTheCertificate() + { + var handler = BuildHandlerWithOrgAndCreate() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{OrderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(OrderId, "CREATED")), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(OrderId, "CREATED")), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(OrderId, "DIGI_ISSUED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + Assert.NotNull(result.Certificate); + Assert.Equal(3, OrderFetchCount(handler)); + } + + [Fact] + public async Task EnrollCertificateAsync_WhenIssuanceNeverCompletesWithinTheBudget_FallsBackToThePendingStatus() + { + var handler = BuildHandlerWithOrgAndCreate() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{OrderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(OrderId, "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config(pickupRetries: 3)); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal(3, OrderFetchCount(handler)); + } + + [Fact] + public async Task EnrollCertificateAsync_WhenTheBudgetExhaustsWithStatusIssuedButNoCertBodyYet_ReportsInProcessNotAFalseGenerated() + { + // Regression test: if MarkMonitor's status flips to issued a moment before the cert body is + // actually populated, IsPollingComplete correctly keeps polling (it requires both) - but if + // the budget exhausts at exactly that moment, the order comes back with an "issued" status + // and a null cert. Reporting GENERATED with Certificate=null would be an internally + // inconsistent result no caller expects for a successful enrollment. + var handler = BuildHandlerWithOrgAndCreate() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{OrderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderIssuedWithoutCertBody(OrderId))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config(pickupRetries: 3)); + + Assert.Equal((int)EndEntityStatus.INPROCESS, result.Status); + Assert.Null(result.Certificate); + } + + [Fact] + public async Task EnrollCertificateAsync_WithPickupRetriesZero_SkipsPollingEntirely() + { + var handler = BuildHandlerWithOrgAndCreate(); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config(pickupRetries: 0)); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal(0, OrderFetchCount(handler)); + } + + [Fact] + public async Task EnrollCertificateAsync_WhenTheCreateOrderResponseItselfIsIssuedWithoutACertBody_ReportsInProcessNotAFalseGenerated() + { + // Same consistency check applies even with polling disabled entirely (PickupRetries=0) - the + // inconsistency can in principle come straight from the order-create response itself, not + // only from a poll response. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, SampleOrders.OrderIssuedWithoutCertBody(OrderId))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config(pickupRetries: 0)); + + Assert.Equal((int)EndEntityStatus.INPROCESS, result.Status); + Assert.Null(result.Certificate); + } + + [Fact] + public async Task EnrollCertificateAsync_WhenTheOrderReachesATerminalFailureWhilePolling_StopsPollingEarly() + { + var handler = BuildHandlerWithOrgAndCreate() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{OrderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(OrderId, "DIGI_FAILED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config(pickupRetries: 5)); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + // Stopped after the first poll observed the terminal state, not all 5 configured attempts. + Assert.Equal(1, OrderFetchCount(handler)); + } + + [Fact] + public async Task EnrollCertificateAsync_WhenAPollAttemptFailsTransiently_RetriesOnTheNextAttemptInsteadOfFailingTheEnrollment() + { + var handler = BuildHandlerWithOrgAndCreate() + .WhenAsync(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{OrderId}"), + _ => Task.FromException(new HttpRequestException("Simulated connection reset")), + _ => Task.FromException(new HttpRequestException("Simulated connection reset")), + _ => Task.FromException(new HttpRequestException("Simulated connection reset")), + _ => Task.FromResult(FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert(OrderId, "DIGI_ISSUED")))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config(pickupRetries: 5)); + + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + } + + [Fact] + public async Task EnrollCertificateAsync_PollsUsingTheConfiguredDelayWithoutARealSleep() + { + var recordedDelays = new List(); + var handler = BuildHandlerWithOrgAndCreate() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{OrderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(OrderId, "CREATED")), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(OrderId, "DIGI_ISSUED"))); + var client = handler.BuildClient(delay: (delay, _) => + { + recordedDelays.Add(delay); + return Task.CompletedTask; + }); + await client.AuthenticateAsync(); + + await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), + Config(pickupRetries: 5, pickupDelaySeconds: 7)); + + Assert.Equal(2, recordedDelays.Count); + Assert.All(recordedDelays, d => Assert.Equal(TimeSpan.FromSeconds(7), d)); + } + + [Fact] + public async Task EnrollCertificateAsync_WhenAlreadyIssuedFromTheCreateOrderCall_DoesNotPollAtAll() + { + // A fast-issuing product (not observed for MarkMonitor today, but the check should still be + // correct if it ever happens) shouldn't trigger any polling at all. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, SampleOrders.OrderWithCert(OrderId, "DIGI_ISSUED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), Config()); + + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + Assert.Equal(0, OrderFetchCount(handler)); + } + + [Fact] + public async Task EnrollCertificateAsync_WhenAPollAttemptFailsRepeatedly_EachOuterPollIsASingleHttpAttempt() + { + // Regression test: each poll's own order-fetch used to go through SendWithRetryAsync (up to + // 3 attempts per poll), multiplying a single hung/slow poll far past the documented + // PickupRetries*PickupDelaySeconds latency ceiling. It's now a single-attempt fetch, so a + // failure is retried only by the OUTER polling loop (one more poll delay + attempt), not + // absorbed 3-at-a-time inside a single outer attempt. + var pollDelayCount = 0; + var handler = BuildHandlerWithOrgAndCreate() + .WhenAsync(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{OrderId}"), + _ => Task.FromException(new HttpRequestException("Simulated connection reset")), + _ => Task.FromException(new HttpRequestException("Simulated connection reset")), + _ => Task.FromException(new HttpRequestException("Simulated connection reset")), + _ => Task.FromResult(FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert(OrderId, "DIGI_ISSUED")))); + var client = handler.BuildClient(delay: (_, _) => + { + pollDelayCount++; + return Task.CompletedTask; + }); + await client.AuthenticateAsync(); + + var result = await client.EnrollCertificateAsync(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), "SslDvGeotrust", new Dictionary(), + Config(pickupRetries: 5)); + + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + // 4 outer poll attempts (3 failures + 1 success), each a single HTTP request - if a poll + // attempt still retried internally, the 3 failures would be absorbed within one outer + // attempt (SendWithRetryAsync's own MaxRetryAttempts=3), needing only 2 outer attempts. + Assert.Equal(4, pollDelayCount); + Assert.Equal(4, OrderFetchCount(handler)); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientQueryEncodingTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientQueryEncodingTests.cs new file mode 100644 index 0000000..b5bd7c6 --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientQueryEncodingTests.cs @@ -0,0 +1,65 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +public class MarkMonitorClientQueryEncodingTests +{ + [Fact] + public async Task ListGroupsAsync_WithSpecialCharactersInName_UrlEncodesTheQueryValue() + { + // MarkmonitorGroup is a template parameter supplied by whoever requests the certificate + // through Command, not just an admin - an unescaped "&"/"#"/space could inject extra query + // parameters or corrupt the request line. + const string groupName = "R&D Team #1"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/auth/v1/group"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + """{"groups":[],"page":{"size":0,"totalElements":0,"totalPages":0,"number":0}}""")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await client.ListGroupsAsync(0, 0, groupName); + + var groupRequest = Assert.Single(handler.Requests, req => FakeHttpMessageHandler.Is(req, "GET", "/group")); + var query = groupRequest.RequestUri!.Query; + Assert.DoesNotContain("R&D Team #1", query); + Assert.Contains(Uri.EscapeDataString(groupName), query); + } + + [Fact] + public async Task ListOrganizationsAsync_WithSpecialCharactersInName_UrlEncodesTheQueryValue() + { + const string orgName = "Acme & Sons"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrgs.OrgsListResponse(""))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await client.ListOrganizationsAsync(0, 1, orgName); + + var orgRequest = + Assert.Single(handler.Requests, req => FakeHttpMessageHandler.Is(req, "GET", "/organization")); + var query = orgRequest.RequestUri!.Query; + Assert.DoesNotContain("Acme & Sons", query); + Assert.Contains(Uri.EscapeDataString(orgName), query); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientRetryTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientRetryTests.cs new file mode 100644 index 0000000..67c318c --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientRetryTests.cs @@ -0,0 +1,225 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using System.Net.Http.Headers; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +/// +/// Coverage for SendWithRetryAsync (transient 5xx/429/network-failure retry with backoff) and the +/// configurable HttpClient timeout - and, just as importantly, that CreateCertificateOrder's +/// order-create POST deliberately does NOT go through that retry path (see its own comment). +/// +[Collection(LogHandlerFactoryCollection.Name)] +public class MarkMonitorClientRetryTests +{ + private static int OrgRequestCount(FakeHttpMessageHandler handler) => + handler.Requests.Count(r => FakeHttpMessageHandler.Is(r, "GET", "/certs/v1/organization")); + + [Fact] + public async Task ListOrganizationsAsync_WhenATransient500IsFollowedBySuccess_RetriesAndSucceeds() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, + """{"errors":[{"code":"request.genericError","message":"Transient failure"}]}"""), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.ListOrganizationsAsync(); + + Assert.NotEmpty(result); + Assert.Equal(2, OrgRequestCount(handler)); + } + + [Fact] + public async Task ListOrganizationsAsync_WhenEveryAttemptReturns500_ThrowsAfterExactlyMaxRetryAttempts() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, + """{"errors":[{"code":"request.genericError","message":"Persistent failure"}]}""")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var ex = await Assert.ThrowsAsync(() => client.ListOrganizationsAsync()); + + Assert.Contains("Persistent failure", ex.Message); + Assert.Equal(3, OrgRequestCount(handler)); + } + + [Fact] + public async Task ListOrganizationsAsync_WhenTheApiReturns400_DoesNotRetry() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.BadRequest, + """{"errors":[{"code":"request.badRequest","message":"Bad request"}]}""")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var ex = await Assert.ThrowsAsync(() => client.ListOrganizationsAsync()); + + Assert.Contains("Bad request", ex.Message); + Assert.Equal(1, OrgRequestCount(handler)); + } + + [Fact] + public async Task ListOrganizationsAsync_WhenEveryAttemptThrowsANetworkError_ThrowsAfterExactlyMaxRetryAttempts() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .WhenAsync(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + _ => Task.FromException(new HttpRequestException("Simulated connection reset"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.ListOrganizationsAsync()); + + Assert.Equal(3, OrgRequestCount(handler)); + } + + [Fact] + public async Task ListOrganizationsAsync_On429WithRetryAfterHeader_WaitsForTheAdvertisedDuration() + { + var recordedDelays = new List(); + var rateLimited = FakeHttpMessageHandler.Json(HttpStatusCode.TooManyRequests, "{}"); + rateLimited.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(2)); + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + rateLimited, + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))); + var client = handler.BuildClient(delay: (delay, _) => + { + recordedDelays.Add(delay); + return Task.CompletedTask; + }); + await client.AuthenticateAsync(); + + var result = await client.ListOrganizationsAsync(); + + Assert.NotEmpty(result); + Assert.Equal(2, OrgRequestCount(handler)); + Assert.Equal(TimeSpan.FromSeconds(2), Assert.Single(recordedDelays)); + } + + [Fact] + public async Task ListOrganizationsAsync_On429WithAnExcessiveRetryAfterHeader_CapsTheDelay() + { + // Regression test: Retry-After is server-controlled input - a misbehaving/compromised + // endpoint returning an enormous value must not be trusted verbatim, since that delay can + // execute while _authLock is held (the auth call path) with no way to cancel it. + var recordedDelays = new List(); + var rateLimited = FakeHttpMessageHandler.Json(HttpStatusCode.TooManyRequests, "{}"); + rateLimited.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromDays(1)); + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + rateLimited, + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))); + var client = handler.BuildClient(delay: (delay, _) => + { + recordedDelays.Add(delay); + return Task.CompletedTask; + }); + await client.AuthenticateAsync(); + + var result = await client.ListOrganizationsAsync(); + + Assert.NotEmpty(result); + Assert.Equal(TimeSpan.FromSeconds(120), Assert.Single(recordedDelays)); + } + + [Fact] + public async Task ListOrganizationsAsync_On500WithoutRetryAfter_BacksOffExponentiallyWithJitter() + { + var recordedDelays = new List(); + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, "{}"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, "{}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))); + var client = handler.BuildClient(delay: (delay, _) => + { + recordedDelays.Add(delay); + return Task.CompletedTask; + }); + await client.AuthenticateAsync(); + + await client.ListOrganizationsAsync(); + + Assert.Equal(2, recordedDelays.Count); + // Nominal 1s/2s ±25% jitter. + Assert.InRange(recordedDelays[0].TotalSeconds, 0.75, 1.25); + Assert.InRange(recordedDelays[1].TotalSeconds, 1.5, 2.5); + } + + [Fact] + public async Task CreateCertificateOrder_WhenTheApiReturns500_DoesNotRetryTheOrderCreatePost() + { + // The order-create POST must never be retried at this layer - a transport/5xx failure here + // is ambiguous about whether MarkMonitor already created the order, and EnrollCertificateAsync's + // dedup reservation (not this client-level retry) is what handles a caller-level retry safely. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, + """{"errors":[{"code":"request.genericError","message":"Transient failure"}]}"""), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("11111111-1111-1111-1111-111111111111", "CREATED"))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.CreateCertificateOrder(new() + { + Cert = new() { CommonName = "test.mmcertdomain.com" }, + CertType = "SSL_DV_GEOTRUST", + Provider = "DIGICERT" + })); + + Assert.Equal(1, handler.Requests.Count(r => FakeHttpMessageHandler.Is(r, "POST", "/certs/v1/order"))); + } + + [Fact] + public void Constructor_WithATimeoutSecondsValue_FlowsItToTheUnderlyingHttpClient() + { + var handler = new FakeHttpMessageHandler().WithSuccessfulAuth(); + var client = new MarkMonitorClient("https://api.markmonitor.test", "key", "user", "pass", true, handler, + timeoutSeconds: 45); + + Assert.Equal(TimeSpan.FromSeconds(45), client.HttpTimeout); + } + + [Fact] + public void Constructor_WithNoTimeoutSecondsValue_DefaultsTo120Seconds() + { + var handler = new FakeHttpMessageHandler().WithSuccessfulAuth(); + var client = new MarkMonitorClient("https://api.markmonitor.test", "key", "user", "pass", true, handler); + + Assert.Equal(TimeSpan.FromSeconds(120), client.HttpTimeout); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientRevokeTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientRevokeTests.cs new file mode 100644 index 0000000..de32388 --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientRevokeTests.cs @@ -0,0 +1,151 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +public class MarkMonitorClientRevokeTests +{ + [Theory] + [InlineData(0u)] + [InlineData(1u)] // keyCompromise + [InlineData(4u)] // superseded + public async Task RevokeCertificateAsync_WithAnyReasonCode_StillRevokesSuccessfully(uint reason) + { + // MarkMonitor's revoke API has no field for a reason code at all (confirmed against its + // published schema - OrderActionPatchObject only has cert/ignoreOrgCheck/additionalEmails), + // so the reason can't change what's sent. This just confirms passing one doesn't break the + // call - the actual "can't forward it" behavior is logged, not independently observable. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order/11111111-1111-1111-1111-111111111111"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert("11111111-1111-1111-1111-111111111111", "DIGI_ISSUED"))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", "/certs/v1/order/11111111-1111-1111-1111-111111111111/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.RevokeCertificateAsync("11111111-1111-1111-1111-111111111111", "Test Org", reason); + + Assert.True(result); + } + + [Fact] + public async Task RevokeCertificateAsync_WhenTheOrderBelongsToADifferentOrganization_ThrowsWithoutRevoking() + { + // The prior cert's request ID in the RenewOrReissue path comes from Command's + // ICertificateDataReader, not from this org's own enrollment - so verify the order actually + // belongs to the configured org before revoking it, rather than trusting the caller. + const string orderId = "11111111-1111-1111-1111-111111111111"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{orderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert(orderId, "DIGI_ISSUED", + organizationId: "99999999-9999-9999-9999-999999999999"))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{orderId}/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await Assert.ThrowsAsync(() => client.RevokeCertificateAsync(orderId, "Test Org")); + + Assert.DoesNotContain(handler.Requests, r => FakeHttpMessageHandler.Is(r, "PATCH", "/revoke")); + } + + [Fact] + public async Task RevokeCertificateAsync_WithOrgNameGivenAsAGuid_ResolvesWithoutAnOrganizationLookup() + { + const string orderId = "11111111-1111-1111-1111-111111111111"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{orderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert(orderId, "DIGI_ISSUED", organizationId: SampleOrgs.DefaultOrgId))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{orderId}/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.RevokeCertificateAsync(orderId, SampleOrgs.DefaultOrgId); + + Assert.True(result); + Assert.DoesNotContain(handler.Requests, r => FakeHttpMessageHandler.Is(r, "GET", "/certs/v1/organization")); + } + + [Fact] + public async Task RevokeCertificateAsync_WithOrgNameGuidInADifferentTextualFormat_StillMatchesTheOrder() + { + // Guid.TryParse accepts several textual formats (braces, no dashes, etc.) that an admin could + // legitimately configure, but MarkMonitor's API always serializes organizationId in one + // canonical form. The comparison must be by parsed Guid value, not raw string equality, or a + // legitimately-configured OrgId in a non-canonical format would be rejected as "wrong org". + const string orderId = "11111111-1111-1111-1111-111111111111"; + const string orgIdBraces = "{11111111-1111-1111-1111-111111111111}"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{orderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert(orderId, "DIGI_ISSUED", organizationId: SampleOrgs.DefaultOrgId))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{orderId}/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.RevokeCertificateAsync(orderId, orgIdBraces); + + Assert.True(result); + } + + [Fact] + public async Task RevokeCertificateAsync_CalledTwiceWithTheSameOrgName_OnlyResolvesTheOrganizationOnce() + { + // Regression test: resolving OrgName by friendly name used to make a fresh MarkMonitor API + // call on every single Revoke/Cancel call, even though the configured value is invariant for + // the connector's (and this cached client's) lifetime - real cost at bulk-revocation scale. + // A resolved org GUID is now cached for this client's lifetime. + const string firstOrderId = "11111111-1111-1111-1111-111111111111"; + const string secondOrderId = "22222222-2222-2222-2222-222222222222"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{firstOrderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert(firstOrderId, "DIGI_ISSUED"))) + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{secondOrderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert(secondOrderId, "DIGI_ISSUED"))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", "/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await client.RevokeCertificateAsync(firstOrderId, "Test Org"); + await client.RevokeCertificateAsync(secondOrderId, "Test Org"); + + Assert.Single(handler.Requests, r => FakeHttpMessageHandler.Is(r, "GET", "/certs/v1/organization")); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientSanTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientSanTests.cs new file mode 100644 index 0000000..dd2ffef --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientSanTests.cs @@ -0,0 +1,175 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +/// +/// Regression coverage for wiring Enroll's `san` dictionary (and any CSR-embedded SAN extension) +/// into the MarkMonitor order's `dnsNames` field - previously accepted and silently dropped, so a +/// multi-SAN enrollment issued CN-only. +/// +[Collection(LogHandlerFactoryCollection.Name)] +public class MarkMonitorClientSanTests +{ + private static FakeHttpMessageHandler BuildHandler(string orderId = "11111111-1111-1111-1111-111111111111") => + new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, SampleOrders.OrderWithCert(orderId, "CREATED"))); + + private static async Task EnrollAndGetSentBody(string csrPem, string subject, + Dictionary? san, FakeHttpMessageHandler? handler = null) + { + handler ??= BuildHandler(); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + await client.EnrollCertificateAsync(csrPem, subject, san, "SslDvGeotrust", + new Dictionary(), SampleConfig.Default()); + + var orderRequest = handler.Requests.Single(req => FakeHttpMessageHandler.Is(req, "POST", "/order")); + var body = await orderRequest.Content!.ReadAsStringAsync(); + return JObject.Parse(body); + } + + private static List? DnsNamesOf(JObject body) => + body["cert"]!["dnsNames"]?.Values().Select(v => v!).ToList(); + + [Fact] + public async Task EnrollCertificateAsync_WithDnsSansInDictionary_PopulatesDnsNamesExcludingCn() + { + var san = new Dictionary { ["Dns"] = ["www.mmcertdomain.com", "test.mmcertdomain.com"] }; + + var body = await EnrollAndGetSentBody(SampleCsr.Pem, "CN=test.mmcertdomain.com", san); + + Assert.Equal(["www.mmcertdomain.com"], DnsNamesOf(body)); + } + + [Fact] + public async Task EnrollCertificateAsync_WithDnsnameKeyCasing_IsAcceptedCaseInsensitively() + { + var san = new Dictionary { ["dnsname"] = ["alt.mmcertdomain.com"] }; + + var body = await EnrollAndGetSentBody(SampleCsr.Pem, "CN=test.mmcertdomain.com", san); + + Assert.Equal(["alt.mmcertdomain.com"], DnsNamesOf(body)); + } + + [Fact] + public async Task EnrollCertificateAsync_WithEmptySanDictionaryAndNoCsrSans_OmitsDnsNamesFromTheRequest() + { + var body = await EnrollAndGetSentBody(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary()); + + Assert.Null(body["cert"]!["dnsNames"]); + } + + [Fact] + public async Task EnrollCertificateAsync_WithDuplicateDnsSans_DedupesThem() + { + var san = new Dictionary + { + ["Dns"] = ["www.mmcertdomain.com", "WWW.mmcertdomain.com", "www.mmcertdomain.com"] + }; + + var body = await EnrollAndGetSentBody(SampleCsr.Pem, "CN=test.mmcertdomain.com", san); + + Assert.Equal(["www.mmcertdomain.com"], DnsNamesOf(body)); + } + + [Fact] + public async Task EnrollCertificateAsync_WithNonDnsSanTypes_DropsThemAndLogsAWarning() + { + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + var san = new Dictionary + { + ["Dns"] = ["www.mmcertdomain.com"], + ["IpAddress"] = ["10.0.0.1"], + ["Email"] = ["admin@mmcertdomain.com"] + }; + + var body = await EnrollAndGetSentBody(SampleCsr.Pem, "CN=test.mmcertdomain.com", san); + + Assert.Equal(["www.mmcertdomain.com"], DnsNamesOf(body)); + var warning = Assert.Single(capturingFactory.Entries, + e => e.Level == LogLevel.Warning && e.Message.Contains("non-DNS SAN", StringComparison.Ordinal)); + Assert.Contains("IpAddress", warning.Message); + Assert.Contains("Email", warning.Message); + } + + [Fact] + public async Task EnrollCertificateAsync_WithSansOnlyEmbeddedInTheCsrAndNoSanDictionaryAtAll_UnionsThemIntoDnsNames() + { + // `san` is genuinely null here - Command never populated SAN data at all for this request - + // which is the only condition that falls back to the CSR's own SAN extension. + var csrPem = SampleCsrWithSans.GeneratePem("test.mmcertdomain.com", "csr-san.mmcertdomain.com"); + + var body = await EnrollAndGetSentBody(csrPem, "CN=test.mmcertdomain.com", null); + + Assert.Equal(["csr-san.mmcertdomain.com"], DnsNamesOf(body)); + } + + [Fact] + public async Task EnrollCertificateAsync_WithSansInBothTheDictionaryAndTheCsr_UsesOnlyTheDictionaryAndIgnoresTheCsr() + { + // Regression test for a security concern raised in review: a non-null `san` dictionary - + // even one that omits a domain the CSR itself carries - means Command's own enrollment + // pattern/template ran and is authoritative for this request. The CSR is subscriber-generated + // and outside Command's policy/RA control, so its own SAN extension must never be unioned in + // (let alone let through unauthorized domains) once Command has supplied a real dictionary - + // certinext-caplugin reverted the identical unconditional-union pattern for this exact reason. + var csrPem = SampleCsrWithSans.GeneratePem("test.mmcertdomain.com", "csr-only.mmcertdomain.com", + "shared.mmcertdomain.com"); + var san = new Dictionary { ["Dns"] = ["dict-san.mmcertdomain.com", "shared.mmcertdomain.com"] }; + + var body = await EnrollAndGetSentBody(csrPem, "CN=test.mmcertdomain.com", san); + + Assert.Equal(new HashSet { "dict-san.mmcertdomain.com", "shared.mmcertdomain.com" }, + DnsNamesOf(body)!.ToHashSet()); + Assert.DoesNotContain("csr-only.mmcertdomain.com", DnsNamesOf(body)!); + } + + [Fact] + public async Task EnrollCertificateAsync_WithANonNullEmptySanDictionaryAndCsrSans_IgnoresTheCsrSans() + { + // An empty-but-non-null dictionary means Command's enrollment pattern deliberately produced + // no SANs for this request - that must be respected, not silently overridden by the CSR. + var csrPem = SampleCsrWithSans.GeneratePem("test.mmcertdomain.com", "csr-san.mmcertdomain.com"); + + var body = await EnrollAndGetSentBody(csrPem, "CN=test.mmcertdomain.com", + new Dictionary()); + + Assert.Null(body["cert"]!["dnsNames"]); + } + + [Fact] + public async Task EnrollCertificateAsync_WithCsrSanMatchingTheCnAndNoSanDictionary_ExcludesItFromDnsNames() + { + var csrPem = SampleCsrWithSans.GeneratePem("test.mmcertdomain.com", "test.mmcertdomain.com", + "extra.mmcertdomain.com"); + + var body = await EnrollAndGetSentBody(csrPem, "CN=test.mmcertdomain.com", null); + + Assert.Equal(["extra.mmcertdomain.com"], DnsNamesOf(body)); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientStatusMappingTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientStatusMappingTests.cs new file mode 100644 index 0000000..33ce106 --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientStatusMappingTests.cs @@ -0,0 +1,66 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; +using Keyfactor.PKI.Enums.EJBCA; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +public class MarkMonitorClientStatusMappingTests +{ + private static async Task GetMappedStatus(string markMonitorStatus) + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order/11111111-1111-1111-1111-111111111111"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert("11111111-1111-1111-1111-111111111111", markMonitorStatus))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + + var result = await client.GetSingleOrderAsync("11111111-1111-1111-1111-111111111111"); + Assert.NotNull(result); + return result.Status; + } + + [Fact] + public async Task CreatedStatus_MapsToExternalValidation_NotInitialized() + { + // github.com/Keyfactor/markmonitor-caplugin/issues/2, verified against a real + // AnyGatewayREST + Command deployment: INITIALIZED (20) is not what the gateway framework + // treats as "accepted, still pending" - only EXTERNALVALIDATION (90) is. Mapping CREATED to + // INITIALIZED caused the gateway to report a hard enrollment failure for an order that had + // actually been created successfully at MarkMonitor and was simply awaiting DCV/issuance. + var status = await GetMappedStatus("CREATED"); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, status); + } + + [Theory] + [InlineData("DIGI_PENDING", EndEntityStatus.INPROCESS)] + [InlineData("DIGI_PROCESSING", EndEntityStatus.INPROCESS)] + [InlineData("DIGI_ISSUED", EndEntityStatus.GENERATED)] + [InlineData("DIGI_REVOKED", EndEntityStatus.REVOKED)] + [InlineData("DIGI_FAILED", EndEntityStatus.FAILED)] + [InlineData("DIGI_CANCELED", EndEntityStatus.CANCELLED)] + [InlineData("DIGI_REJECTED", EndEntityStatus.CANCELLED)] + public async Task OtherStatuses_MapAsExpected(string markMonitorStatus, EndEntityStatus expected) + { + var status = await GetMappedStatus(markMonitorStatus); + + Assert.Equal((int)expected, status); + } +} diff --git a/markmonitor-caplugin.Tests/Client/MarkMonitorClientSyncResilienceTests.cs b/markmonitor-caplugin.Tests/Client/MarkMonitorClientSyncResilienceTests.cs new file mode 100644 index 0000000..c8663ad --- /dev/null +++ b/markmonitor-caplugin.Tests/Client/MarkMonitorClientSyncResilienceTests.cs @@ -0,0 +1,303 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Concurrent; +using System.Net; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; +using Keyfactor.PKI.Enums.EJBCA; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Client; + +/// +/// Coverage for GetCertificateInventoryAsync's per-record isolation, error-rate circuit breaker, +/// and skip-unchanged optimization added to close the "one bad record aborts the whole sync" and +/// "every sync re-emits every order" gaps. +/// +public class MarkMonitorClientSyncResilienceTests +{ + private const string GoodId = "77777777-7777-7777-7777-777777777777"; + + [Fact] + public async Task GetCertificateInventoryAsync_WhenOneRecordThrowsDuringProcessing_SkipsItButKeepsTheRest() + { + // Simulates the realistic "bad record" failure surface: a downstream ICertificateDataReader + // lookup failure (e.g. a transient database error) for one specific order, rather than a + // malformed API response - MarkMonitorOrder's DateValidUntil/status fields are already + // strongly-typed DateTime/string, so a genuinely malformed value would fail JSON + // deserialization of the whole page, not per-record processing. + var badId = "99999999-9999-9999-9999-999999999999"; + var otherGoodId = "88888888-8888-8888-8888-888888888888"; + var reader = new FakeCertificateDataReader(); + reader.ThrowForRequestIds.Add(badId); + var ordersJson = string.Join(",", new[] + { + SampleOrders.OrderWithCert(GoodId, "DIGI_ISSUED"), + SampleOrders.OrderWithCert(badId, "DIGI_ISSUED"), + SampleOrders.OrderWithCert(otherGoodId, "DIGI_ISSUED") + }); + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrdersPage(ordersJson))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var buffer = new BlockingCollection(); + + var count = await client.GetCertificateInventoryAsync("", "", 100, buffer, CancellationToken.None, reader); + + Assert.Equal(2, count); + var collected = buffer.ToList(); + Assert.Equal(2, collected.Count); + Assert.DoesNotContain(collected, c => c.CARequestID == badId); + Assert.Contains(collected, c => c.CARequestID == GoodId); + Assert.Contains(collected, c => c.CARequestID == otherGoodId); + } + + [Fact] + public async Task GetCertificateInventoryAsync_WhenErrorRateExceeds25PercentAfter50Records_AbortsTheSync() + { + // 50 good records first (ratio stays 0% - the breaker isn't evaluated below the 50-record + // sample size floor), then enough bad ones that the cumulative error rate crosses 25% - the + // 67th record observed (17 bad / 67 = 25.37%) is where this trips. + var reader = new FakeCertificateDataReader(); + var records = new List(); + for (var i = 0; i < 50; i++) + records.Add(SampleOrders.OrderWithCert(Guid.NewGuid().ToString(), "DIGI_ISSUED")); + for (var i = 0; i < 20; i++) + { + var badId = Guid.NewGuid().ToString(); + reader.ThrowForRequestIds.Add(badId); + records.Add(SampleOrders.OrderWithCert(badId, "DIGI_ISSUED")); + } + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrdersPage(string.Join(",", records)))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var buffer = new BlockingCollection(); + + var ex = await Assert.ThrowsAsync( + () => client.GetCertificateInventoryAsync("", "", 100, buffer, CancellationToken.None, reader)); + + Assert.Contains("Aborting synchronization", ex.Message); + Assert.True(buffer.IsAddingCompleted); + } + + [Fact] + public async Task GetCertificateInventoryAsync_WhenBelow50Records_DoesNotTripTheCircuitBreakerEvenIfEveryOneFails() + { + var reader = new FakeCertificateDataReader(); + var records = new List(); + for (var i = 0; i < 20; i++) + { + var badId = Guid.NewGuid().ToString(); + reader.ThrowForRequestIds.Add(badId); + records.Add(SampleOrders.OrderWithCert(badId, "DIGI_ISSUED")); + } + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrdersPage(string.Join(",", records)))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var buffer = new BlockingCollection(); + + var count = await client.GetCertificateInventoryAsync("", "", 100, buffer, CancellationToken.None, reader); + + Assert.Equal(0, count); + } + + [Fact] + public async Task GetCertificateInventoryAsync_WhenARecordIsUnchanged_SkipsReEmittingIt() + { + var reader = new FakeCertificateDataReader(); + reader.RequestIdToStatus[GoodId] = (int)EndEntityStatus.GENERATED; // DIGI_ISSUED maps to GENERATED + // SampleOrders.OrderWithCert's default dateValidUntil - matching this proves "truly unchanged" + // (same status AND same expiration), not just a status coincidence. + reader.ExpirationDateByRequestId[GoodId] = DateTime.Parse("2027-01-01T00:00:00Z").ToUniversalTime(); + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrdersPage(SampleOrders.OrderWithCert(GoodId, "DIGI_ISSUED")))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var buffer = new BlockingCollection(); + + var count = await client.GetCertificateInventoryAsync("", "", 100, buffer, CancellationToken.None, reader); + + Assert.Equal(0, count); + Assert.Empty(buffer.ToList()); + } + + [Fact] + public async Task GetCertificateInventoryAsync_WhenStatusMatchesButExpirationDiffers_StillEmitsIt() + { + // Regression test: an out-of-band MarkMonitor reissue of the same order ID round-trips + // DIGI_ISSUED -> DIGI_REISSUE_PENDING -> DIGI_ISSUED - if a sync only observes the order + // before and after that round-trip, status alone looks unchanged even though the certificate + // (and its expiration) is new. Comparing expiration too catches this. + var reader = new FakeCertificateDataReader(); + reader.RequestIdToStatus[GoodId] = (int)EndEntityStatus.GENERATED; + reader.ExpirationDateByRequestId[GoodId] = DateTime.Parse("2026-06-01T00:00:00Z").ToUniversalTime(); + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + // Default dateValidUntil (2027-01-01) differs from the reader's stored 2026-06-01 - + // simulating a reissued certificate with a new validity window at the same status. + SampleOrders.OrdersPage(SampleOrders.OrderWithCert(GoodId, "DIGI_ISSUED")))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var buffer = new BlockingCollection(); + + var count = await client.GetCertificateInventoryAsync("", "", 100, buffer, CancellationToken.None, reader); + + Assert.Equal(1, count); + Assert.Contains(buffer.ToList(), c => c.CARequestID == GoodId); + } + + [Fact] + public async Task GetCertificateInventoryAsync_WhenNoExpirationDataForTheRequestId_StillEmitsIt() + { + // Command not (yet) tracking an expiration for this request ID must not be treated as a + // false match - erring toward re-emitting rather than skipping when uncertain. + var reader = new FakeCertificateDataReader(); + reader.RequestIdToStatus[GoodId] = (int)EndEntityStatus.GENERATED; + // No ExpirationDateByRequestId entry - GetExpirationDateByRequestId returns null. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrdersPage(SampleOrders.OrderWithCert(GoodId, "DIGI_ISSUED")))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var buffer = new BlockingCollection(); + + var count = await client.GetCertificateInventoryAsync("", "", 100, buffer, CancellationToken.None, reader); + + Assert.Equal(1, count); + } + + [Fact] + public async Task GetCertificateInventoryAsync_WhenARecordsStatusChanged_StillEmitsIt() + { + var reader = new FakeCertificateDataReader(); + reader.RequestIdToStatus[GoodId] = (int)EndEntityStatus.INPROCESS; // was pending, now issued + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrdersPage(SampleOrders.OrderWithCert(GoodId, "DIGI_ISSUED")))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var buffer = new BlockingCollection(); + + var count = await client.GetCertificateInventoryAsync("", "", 100, buffer, CancellationToken.None, reader); + + Assert.Equal(1, count); + Assert.Contains(buffer.ToList(), c => c.CARequestID == GoodId); + } + + [Fact] + public async Task GetCertificateInventoryAsync_WhenNoCertificateDataReaderIsSupplied_AlwaysEmits() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrdersPage(SampleOrders.OrderWithCert(GoodId, "DIGI_ISSUED")))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var buffer = new BlockingCollection(); + + var count = await client.GetCertificateInventoryAsync("", "", 100, buffer, CancellationToken.None); + + Assert.Equal(1, count); + } + + [Fact] + public async Task GetCertificateInventoryAsync_WithForceCompleteSyncTrue_ReEmitsEvenAnUnchangedRecord() + { + var reader = new FakeCertificateDataReader(); + reader.RequestIdToStatus[GoodId] = (int)EndEntityStatus.GENERATED; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrdersPage(SampleOrders.OrderWithCert(GoodId, "DIGI_ISSUED")))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var buffer = new BlockingCollection(); + + var count = await client.GetCertificateInventoryAsync("", "", 100, buffer, CancellationToken.None, reader, + forceCompleteSync: true); + + Assert.Equal(1, count); + } + + [Fact] + public async Task GetCertificateInventoryAsync_WithAPageLargerThanTheConcurrencyLimit_CountsEveryOutcomeExactlyOnce() + { + // Regression test for concurrent per-record processing (records within a page are now + // processed with bounded concurrency instead of one at a time): a page bigger than the + // concurrency limit (10) exercises multiple concurrent batches, so this asserts the + // Interlocked counters aren't racing/double-counting/dropping increments under that load. + var reader = new FakeCertificateDataReader(); + var records = new List(); + var emittedIds = new List(); + var skippedIds = new List(); + var erroredIds = new List(); + + for (var i = 0; i < 15; i++) + { + var id = Guid.NewGuid().ToString(); + emittedIds.Add(id); + records.Add(SampleOrders.OrderWithCert(id, "DIGI_ISSUED")); + } + for (var i = 0; i < 15; i++) + { + var id = Guid.NewGuid().ToString(); + skippedIds.Add(id); + reader.RequestIdToStatus[id] = (int)EndEntityStatus.GENERATED; + reader.ExpirationDateByRequestId[id] = DateTime.Parse("2027-01-01T00:00:00Z").ToUniversalTime(); + records.Add(SampleOrders.OrderWithCert(id, "DIGI_ISSUED")); + } + for (var i = 0; i < 10; i++) + { + var id = Guid.NewGuid().ToString(); + erroredIds.Add(id); + reader.ThrowForRequestIds.Add(id); + records.Add(SampleOrders.OrderWithCert(id, "DIGI_ISSUED")); + } + + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrdersPage(string.Join(",", records)))); + var client = handler.BuildClient(); + await client.AuthenticateAsync(); + var buffer = new BlockingCollection(); + + var count = await client.GetCertificateInventoryAsync("", "", 100, buffer, CancellationToken.None, reader); + + Assert.Equal(15, count); + var collectedIds = buffer.ToList().Select(c => c.CARequestID).ToHashSet(); + Assert.Equal(new HashSet(emittedIds), collectedIds); + Assert.DoesNotContain(collectedIds, id => skippedIds.Contains(id) || erroredIds.Contains(id)); + } +} diff --git a/markmonitor-caplugin.Tests/MarkMonitorCAPluginClientCachingTests.cs b/markmonitor-caplugin.Tests/MarkMonitorCAPluginClientCachingTests.cs new file mode 100644 index 0000000..b55bfb1 --- /dev/null +++ b/markmonitor-caplugin.Tests/MarkMonitorCAPluginClientCachingTests.cs @@ -0,0 +1,48 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests; + +public class MarkMonitorCAPluginClientCachingTests +{ + [Fact] + public async Task CreateAndAuthenticateClientAsync_CalledTwice_ReturnsTheSameClientInstance() + { + // Before the fix, every IAnyCAPlugin call (Enroll/Revoke/Ping/GetSingleRecord) built a brand + // new MarkMonitorClient - and therefore a brand new HttpClient/HttpClientHandler and a fresh + // authentication round-trip - from scratch. This asserts the plugin now hands back the same + // client across multiple calls instead. + var plugin = new MarkMonitorCAPlugin(); + plugin.Initialize(FakeAnyCAPluginConfigProvider.WithDefaults(), new FakeCertificateDataReader()); + + var first = await plugin.CreateAndAuthenticateClientAsync(); + var second = await plugin.CreateAndAuthenticateClientAsync(); + + Assert.Same(first, second); + } + + [Fact] + public async Task CreateAndAuthenticateClientAsync_CalledConcurrently_OnlyBuildsOneClient() + { + var plugin = new MarkMonitorCAPlugin(); + plugin.Initialize(FakeAnyCAPluginConfigProvider.WithDefaults(), new FakeCertificateDataReader()); + + var results = await Task.WhenAll(Enumerable.Range(0, 10) + .Select(_ => plugin.CreateAndAuthenticateClientAsync())); + + Assert.All(results, r => Assert.Same(results[0], r)); + } +} diff --git a/markmonitor-caplugin.Tests/MarkMonitorCAPluginEnrollTests.cs b/markmonitor-caplugin.Tests/MarkMonitorCAPluginEnrollTests.cs new file mode 100644 index 0000000..ca86a52 --- /dev/null +++ b/markmonitor-caplugin.Tests/MarkMonitorCAPluginEnrollTests.cs @@ -0,0 +1,91 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests; + +[Collection(LogHandlerFactoryCollection.Name)] +public class MarkMonitorCAPluginEnrollTests +{ + [Fact] + public async Task Enroll_WhenMarkMonitorRejectsTheOrder_PropagatesTheRealErrorDetail() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.BadRequest, + """{"validations":[{"field":"cert.csr","code":"field.invalidFormat","message":"The CSR format is invalid."}]}""")); + var injectedClient = handler.BuildClient(); + var plugin = new MarkMonitorCAPlugin(injectedClient); + plugin.Initialize(FakeAnyCAPluginConfigProvider.WithDefaults(), new FakeCertificateDataReader()); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "SslDvGeotrust", + ProductParameters = new Dictionary() + }; + + var ex = await Assert.ThrowsAsync(() => + plugin.Enroll(SampleCsr.Pem, "CN=test.mmcertdomain.com", new Dictionary(), + productInfo, RequestFormat.PKCS10, EnrollmentType.New)); + + // Before the fix, Command would only ever see the generic + // "Enrollment failed for subject: ..." message - the real MarkMonitor + // validation detail never made it past EnrollCertificateAsync's catch block. + Assert.Contains("The CSR format is invalid", ex.Message); + } + + [Fact] + public async Task Enroll_WithASubjectContainingEmbeddedCrLf_SanitizesItInLogOutputButStillEnrollsSuccessfully() + { + // Regression test (CWE-117): Subject is fully requester-controlled (straight off the + // submitted CSR). Before the fix, an embedded CR/LF was logged raw, letting a requester forge + // a fake log line that could be mistaken for a genuine, unrelated entry by anyone relying on + // this plugin's logs to reconstruct certificate-issuance history. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + + const string maliciousSubject = + "CN=evil.example\r\n2026-08-10 09:00:00 [INF] Enrollment completed successfully for subject: CN=innocent.example"; + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert("11111111-1111-1111-1111-111111111111", "CREATED"))); + var plugin = new MarkMonitorCAPlugin(handler.BuildClient()); + plugin.Initialize(FakeAnyCAPluginConfigProvider.WithDefaults(), new FakeCertificateDataReader()); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "SslDvGeotrust", + ProductParameters = new Dictionary() + }; + + var result = await plugin.Enroll(SampleCsr.Pem, maliciousSubject, new Dictionary(), + productInfo, RequestFormat.PKCS10, EnrollmentType.New); + + Assert.NotNull(result); + Assert.DoesNotContain(capturingFactory.Messages, m => m.Contains("\r\n", StringComparison.Ordinal)); + Assert.Contains(capturingFactory.Messages, m => m.Contains("\\r\\n", StringComparison.Ordinal)); + } +} diff --git a/markmonitor-caplugin.Tests/MarkMonitorCAPluginGetSingleRecordTests.cs b/markmonitor-caplugin.Tests/MarkMonitorCAPluginGetSingleRecordTests.cs new file mode 100644 index 0000000..22efdea --- /dev/null +++ b/markmonitor-caplugin.Tests/MarkMonitorCAPluginGetSingleRecordTests.cs @@ -0,0 +1,41 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests; + +[Collection(LogHandlerFactoryCollection.Name)] +public class MarkMonitorCAPluginGetSingleRecordTests +{ + [Fact] + public async Task GetSingleRecord_WithCaRequestIdContainingCrLf_SanitizesItInLogOutput() + { + // Regression test (CWE-117): GetSingleRecord() used to log the caller-supplied caRequestId + // verbatim before the GUID validation performed deeper inside MarkMonitorClient ever ran, + // letting an embedded CR/LF forge a fake log line. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + + const string maliciousCaRequestId = "not-a-guid\r\n2026-08-10 09:00:00 [INF] FAKE forged log line"; + var handler = new FakeHttpMessageHandler().WithSuccessfulAuth(); + var plugin = new MarkMonitorCAPlugin(handler.BuildClient()); + plugin.Initialize(FakeAnyCAPluginConfigProvider.WithDefaults(), new FakeCertificateDataReader()); + + await Assert.ThrowsAsync(() => plugin.GetSingleRecord(maliciousCaRequestId)); + + Assert.DoesNotContain(capturingFactory.Messages, m => m.Contains("\r\n", StringComparison.Ordinal)); + Assert.Contains(capturingFactory.Messages, m => m.Contains("\\r\\n", StringComparison.Ordinal)); + } +} diff --git a/markmonitor-caplugin.Tests/MarkMonitorCAPluginPingTests.cs b/markmonitor-caplugin.Tests/MarkMonitorCAPluginPingTests.cs new file mode 100644 index 0000000..588ae97 --- /dev/null +++ b/markmonitor-caplugin.Tests/MarkMonitorCAPluginPingTests.cs @@ -0,0 +1,93 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests; + +[Collection(LogHandlerFactoryCollection.Name)] +public class MarkMonitorCAPluginPingTests +{ + [Fact] + public async Task Ping_WithInvalidCredentials_ThrowsWithoutEverLoggingAuthenticationSuccessful() + { + // Regression test: Ping() used to log "Authentication with MarkMonitor API successful" + // immediately after CreateAndAuthenticateClientAsync() - which deliberately does not + // authenticate eagerly, it only builds/caches the client wrapper - so that line was reached, + // and logged, before any credential had actually been checked. With invalid credentials, the + // log stream showed a false "successful" line followed immediately by a real auth failure for + // the same authentication attempt. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + + var handler = new FakeHttpMessageHandler() + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/auth/v1/auth/authenticate"), + FakeHttpMessageHandler.Json(HttpStatusCode.Unauthorized, + """{"errors":[{"code":"auth.invalidCredentials","message":"Invalid credentials."}]}""")); + var plugin = new MarkMonitorCAPlugin(handler.BuildClient()); + plugin.Initialize(FakeAnyCAPluginConfigProvider.WithDefaults(), new FakeCertificateDataReader()); + + await Assert.ThrowsAsync(() => plugin.Ping()); + + Assert.DoesNotContain(capturingFactory.Messages, + m => m.Contains("Authentication with MarkMonitor API successful", StringComparison.Ordinal)); + } + + [Fact] + public async Task Ping_WithValidCredentialsAndOrganizations_LogsAuthenticationSuccessful() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))); + var plugin = new MarkMonitorCAPlugin(handler.BuildClient()); + plugin.Initialize(FakeAnyCAPluginConfigProvider.WithDefaults(), new FakeCertificateDataReader()); + + await plugin.Ping(); + } + + [Fact] + public async Task Ping_RequestsALargeEnoughPageSizeToAvoidOneRoundTripPerOrganization() + { + // Regression test: an earlier version of this fix requested page size 1 as a supposedly cheap + // existence check, but ListOrganizationsAsync's pagination loop has no early exit - it always + // fetches every page up to TotalPages regardless of what the caller needs. With size=1, + // TotalPages equals the account's total organization count, so that "fix" actually turned Ping + // into one sequential HTTP request PER organization instead of the single request intended. + // Two explicit pages (rather than the default single-page fixture, which hardcodes + // totalPages=1 regardless of the requested size and would mask this exact regression) prove + // pagination still terminates correctly and uses the corrected, larger page size. + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization") && + !(req.RequestUri?.Query.Contains("page=") ?? false), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact("11111111-1111-1111-1111-111111111111")) + .Replace("\"totalPages\": 1", "\"totalPages\": 2"))) + .When(req => FakeHttpMessageHandler.Is(req, "GET", "page=1"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact("22222222-2222-2222-2222-222222222222")) + .Replace("\"totalPages\": 1", "\"totalPages\": 2"))); + var plugin = new MarkMonitorCAPlugin(handler.BuildClient()); + plugin.Initialize(FakeAnyCAPluginConfigProvider.WithDefaults(), new FakeCertificateDataReader()); + + await plugin.Ping(); + + var orgRequests = handler.Requests.Where(r => FakeHttpMessageHandler.Is(r, "GET", "/certs/v1/organization")).ToList(); + Assert.Equal(2, orgRequests.Count); + Assert.All(orgRequests, r => Assert.Contains("size=100", r.RequestUri!.Query)); + } +} diff --git a/markmonitor-caplugin.Tests/MarkMonitorCAPluginRenewOrReissueTests.cs b/markmonitor-caplugin.Tests/MarkMonitorCAPluginRenewOrReissueTests.cs new file mode 100644 index 0000000..e4bdf08 --- /dev/null +++ b/markmonitor-caplugin.Tests/MarkMonitorCAPluginRenewOrReissueTests.cs @@ -0,0 +1,336 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests; + +[Collection(LogHandlerFactoryCollection.Name)] +public class MarkMonitorCAPluginRenewOrReissueTests +{ + private static FakeHttpMessageHandler BaseHandler(string newOrderId = "new-order-id") => + new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/certs/v1/order"), + FakeHttpMessageHandler.Json(HttpStatusCode.Accepted, + SampleOrders.OrderWithCert(newOrderId, "CREATED"))); + + private static (MarkMonitorCAPlugin plugin, FakeHttpMessageHandler handler, FakeCertificateDataReader reader) + BuildPlugin(FakeHttpMessageHandler handler) + { + var client = handler.BuildClient(); + var plugin = new MarkMonitorCAPlugin(client); + var reader = new FakeCertificateDataReader(); + plugin.Initialize(FakeAnyCAPluginConfigProvider.WithDefaults(), reader); + return (plugin, handler, reader); + } + + [Fact] + public async Task Enroll_RenewOrReissueWithResolvablePriorCertSN_RevokesThePriorCertificate() + { + var handler = BaseHandler() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/order/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "DIGI_ISSUED"))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", "/certs/v1/order/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var (plugin, _, reader) = BuildPlugin(handler); + reader.SerialNumberToRequestId["ab:cd:ef"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + + var productInfo = new EnrollmentProductInfo + { + ProductID = "SslDvGeotrust", + ProductParameters = new Dictionary { ["PriorCertSN"] = "ab:cd:ef" } + }; + + var result = await plugin.Enroll(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotNull(result); + Assert.Contains(handler.Requests, + req => FakeHttpMessageHandler.Is(req, "PATCH", "/certs/v1/order/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/revoke")); + } + + [Fact] + public async Task Enroll_RenewOrReissueWithoutPriorCertSN_FallsBackToNewEnrollmentWithoutRevoking() + { + var handler = BaseHandler(); + var (plugin, _, _) = BuildPlugin(handler); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "SslDvGeotrust", + ProductParameters = new Dictionary() + }; + + var result = await plugin.Enroll(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotNull(result); + Assert.DoesNotContain(handler.Requests, req => FakeHttpMessageHandler.Is(req, "PATCH", "/revoke")); + } + + [Fact] + public async Task Enroll_RenewOrReissueWithUnresolvablePriorCertSN_StillReturnsTheNewCertWithoutRevoking() + { + var handler = BaseHandler(); + var (plugin, _, _) = BuildPlugin(handler); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "SslDvGeotrust", + ProductParameters = new Dictionary { ["PriorCertSN"] = "unknown-serial" } + }; + + var result = await plugin.Enroll(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotNull(result); + Assert.DoesNotContain(handler.Requests, req => FakeHttpMessageHandler.Is(req, "PATCH", "/revoke")); + } + + [Fact] + public async Task Enroll_RenewOrReissueWithPriorCertWithinTheDefaultRenewalWindow_RevokesIt() + { + var priorId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + var handler = BaseHandler() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{priorId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(priorId, "DIGI_ISSUED"))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{priorId}/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var (plugin, _, reader) = BuildPlugin(handler); + reader.SerialNumberToRequestId["ab:cd:ef"] = priorId; + reader.ExpirationDateByRequestId[priorId] = DateTime.UtcNow.AddDays(10); // well inside the default 90-day window + + var productInfo = new EnrollmentProductInfo + { + ProductID = "SslDvGeotrust", + ProductParameters = new Dictionary { ["PriorCertSN"] = "ab:cd:ef" } + }; + + await plugin.Enroll(SampleCsr.Pem, "CN=test.mmcertdomain.com", new Dictionary(), + productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Contains(handler.Requests, req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{priorId}/revoke")); + } + + [Fact] + public async Task Enroll_RenewOrReissueWithPriorCertOutsideTheDefaultRenewalWindow_LeavesItUnrevoked() + { + var priorId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + var handler = BaseHandler(); + var (plugin, _, reader) = BuildPlugin(handler); + reader.SerialNumberToRequestId["ab:cd:ef"] = priorId; + reader.ExpirationDateByRequestId[priorId] = DateTime.UtcNow.AddDays(200); // still has substantial life left + + var productInfo = new EnrollmentProductInfo + { + ProductID = "SslDvGeotrust", + ProductParameters = new Dictionary { ["PriorCertSN"] = "ab:cd:ef" } + }; + + var result = await plugin.Enroll(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotNull(result); + Assert.DoesNotContain(handler.Requests, req => FakeHttpMessageHandler.Is(req, "PATCH", "/revoke")); + } + + [Fact] + public async Task Enroll_RenewOrReissueWithAnAlreadyExpiredPriorCert_StillRevokesIt() + { + var priorId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + var handler = BaseHandler() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{priorId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(priorId, "DIGI_ISSUED"))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{priorId}/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var (plugin, _, reader) = BuildPlugin(handler); + reader.SerialNumberToRequestId["ab:cd:ef"] = priorId; + reader.ExpirationDateByRequestId[priorId] = DateTime.UtcNow.AddDays(-5); // already expired + + var productInfo = new EnrollmentProductInfo + { + ProductID = "SslDvGeotrust", + ProductParameters = new Dictionary { ["PriorCertSN"] = "ab:cd:ef" } + }; + + await plugin.Enroll(SampleCsr.Pem, "CN=test.mmcertdomain.com", new Dictionary(), + productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Contains(handler.Requests, req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{priorId}/revoke")); + } + + [Fact] + public async Task Enroll_RenewOrReissueWithACustomRenewalWindowDays_RespectsIt() + { + var priorId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + var handler = BaseHandler(); + var (plugin, _, reader) = BuildPlugin(handler); + reader.SerialNumberToRequestId["ab:cd:ef"] = priorId; + // 50 days out - within the default 90-day window, but outside a configured 30-day window. + reader.ExpirationDateByRequestId[priorId] = DateTime.UtcNow.AddDays(50); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "SslDvGeotrust", + ProductParameters = new Dictionary + { ["PriorCertSN"] = "ab:cd:ef", ["RenewalWindowDays"] = "30" } + }; + + var result = await plugin.Enroll(SampleCsr.Pem, "CN=test.mmcertdomain.com", + new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotNull(result); + Assert.DoesNotContain(handler.Requests, req => FakeHttpMessageHandler.Is(req, "PATCH", "/revoke")); + } + + [Fact] + public async Task Enroll_RenewOrReissueWithAnInvalidRenewalWindowDaysValue_FallsBackToTheDefault() + { + var priorId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + var handler = BaseHandler() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{priorId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(priorId, "DIGI_ISSUED"))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{priorId}/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var (plugin, _, reader) = BuildPlugin(handler); + reader.SerialNumberToRequestId["ab:cd:ef"] = priorId; + // 50 days out - within the default 90-day window a non-numeric value must fall back to. + reader.ExpirationDateByRequestId[priorId] = DateTime.UtcNow.AddDays(50); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "SslDvGeotrust", + ProductParameters = new Dictionary + { ["PriorCertSN"] = "ab:cd:ef", ["RenewalWindowDays"] = "not-a-number" } + }; + + await plugin.Enroll(SampleCsr.Pem, "CN=test.mmcertdomain.com", new Dictionary(), + productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Contains(handler.Requests, req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{priorId}/revoke")); + } + + [Fact] + public async Task Enroll_RenewOrReissueWithAnInvalidRenewalWindowDaysValue_LogsAWarningNamingTheRejectedValue() + { + // Regression test: a mistyped/invalid RenewalWindowDays used to be silently coerced to the + // default with zero audit trail - indistinguishable in the logs from "not configured at + // all," despite affecting a security-relevant revoke decision. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + var priorId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + var handler = BaseHandler() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{priorId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(priorId, "DIGI_ISSUED"))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{priorId}/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var (plugin, _, reader) = BuildPlugin(handler); + reader.SerialNumberToRequestId["ab:cd:ef"] = priorId; + reader.ExpirationDateByRequestId[priorId] = DateTime.UtcNow.AddDays(50); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "SslDvGeotrust", + ProductParameters = new Dictionary + { ["PriorCertSN"] = "ab:cd:ef", ["RenewalWindowDays"] = "not-a-number" } + }; + + await plugin.Enroll(SampleCsr.Pem, "CN=test.mmcertdomain.com", new Dictionary(), + productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + var warning = Assert.Single(capturingFactory.Entries, + e => e.Level == LogLevel.Warning && e.Message.Contains("Invalid RenewalWindowDays", StringComparison.Ordinal)); + Assert.Contains("not-a-number", warning.Message); + } + + [Fact] + public async Task Enroll_RenewOrReissueWithNoRenewalWindowDaysParameter_LogsNoWarning() + { + // Absent (never configured) must not be logged the same as present-but-rejected. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + var priorId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + var handler = BaseHandler() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{priorId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(priorId, "DIGI_ISSUED"))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{priorId}/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var (plugin, _, reader) = BuildPlugin(handler); + reader.SerialNumberToRequestId["ab:cd:ef"] = priorId; + reader.ExpirationDateByRequestId[priorId] = DateTime.UtcNow.AddDays(50); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "SslDvGeotrust", + ProductParameters = new Dictionary { ["PriorCertSN"] = "ab:cd:ef" } + }; + + await plugin.Enroll(SampleCsr.Pem, "CN=test.mmcertdomain.com", new Dictionary(), + productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.DoesNotContain(capturingFactory.Entries, + e => e.Level == LogLevel.Warning && e.Message.Contains("Invalid RenewalWindowDays", StringComparison.Ordinal)); + } + + [Fact] + public async Task Enroll_RenewOrReissueWithNoExpirationDataForThePriorCert_FallsBackToRevokingItAsBefore() + { + var priorId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + var handler = BaseHandler() + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{priorId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrders.OrderWithCert(priorId, "DIGI_ISSUED"))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{priorId}/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + var (plugin, _, reader) = BuildPlugin(handler); + reader.SerialNumberToRequestId["ab:cd:ef"] = priorId; + // No ExpirationDateByRequestId entry - GetExpirationDateByRequestId returns null. + + var productInfo = new EnrollmentProductInfo + { + ProductID = "SslDvGeotrust", + ProductParameters = new Dictionary { ["PriorCertSN"] = "ab:cd:ef" } + }; + + await plugin.Enroll(SampleCsr.Pem, "CN=test.mmcertdomain.com", new Dictionary(), + productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Contains(handler.Requests, req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{priorId}/revoke")); + } + + [Fact] + public async Task Enroll_PlainNewEnrollment_NeverAttemptsToRevokeAnything() + { + var handler = BaseHandler(); + var (plugin, _, reader) = BuildPlugin(handler); + reader.SerialNumberToRequestId["ab:cd:ef"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + + var productInfo = new EnrollmentProductInfo + { + ProductID = "SslDvGeotrust", + ProductParameters = new Dictionary { ["PriorCertSN"] = "ab:cd:ef" } + }; + + await plugin.Enroll(SampleCsr.Pem, "CN=test.mmcertdomain.com", new Dictionary(), + productInfo, RequestFormat.PKCS10, EnrollmentType.New); + + Assert.DoesNotContain(handler.Requests, req => FakeHttpMessageHandler.Is(req, "PATCH", "/revoke")); + } +} diff --git a/markmonitor-caplugin.Tests/MarkMonitorCAPluginRevokeTests.cs b/markmonitor-caplugin.Tests/MarkMonitorCAPluginRevokeTests.cs new file mode 100644 index 0000000..c489acd --- /dev/null +++ b/markmonitor-caplugin.Tests/MarkMonitorCAPluginRevokeTests.cs @@ -0,0 +1,108 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; +using Keyfactor.PKI.Enums.EJBCA; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests; + +[Collection(LogHandlerFactoryCollection.Name)] +public class MarkMonitorCAPluginRevokeTests +{ + private const string OrderId = "11111111-1111-1111-1111-111111111111"; + + private static FakeHttpMessageHandler BaseHandler() => + new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))) + .When(req => FakeHttpMessageHandler.Is(req, "GET", $"/certs/v1/order/{OrderId}"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrders.OrderWithCert(OrderId, "DIGI_ISSUED", organizationId: SampleOrgs.DefaultOrgId))) + .When(req => FakeHttpMessageHandler.Is(req, "PATCH", $"/certs/v1/order/{OrderId}/revoke"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}")); + + [Fact] + public async Task Revoke_WithOrgNameConfigured_RevokesSuccessfully() + { + var handler = BaseHandler(); + var plugin = new MarkMonitorCAPlugin(handler.BuildClient()); + plugin.Initialize(FakeAnyCAPluginConfigProvider.WithDefaults(), new FakeCertificateDataReader()); + + var status = await plugin.Revoke(OrderId, "aabbcc", 0); + + Assert.Equal((int)EndEntityStatus.REVOKED, status); + } + + [Fact] + public async Task Revoke_WithBlankOrgNameConfigured_ThrowsWithoutMakingARequest() + { + // A blank/missing OrgId must never silently skip the cross-org ownership check on a live + // connector instance - Initialize() doesn't re-validate the deserialized config, so this is + // the only guard against a misconfigured OrgId reverting Revoke to "trust the caller". + var handler = BaseHandler(); + var plugin = new MarkMonitorCAPlugin(handler.BuildClient()); + var configProvider = FakeAnyCAPluginConfigProvider.WithDefaults(); + configProvider.CAConnectionData[MarkMonitorCAPluginConfig.ConfigConstants.OrgName] = ""; + plugin.Initialize(configProvider, new FakeCertificateDataReader()); + + await Assert.ThrowsAsync(() => plugin.Revoke(OrderId, "aabbcc", 0)); + + Assert.DoesNotContain(handler.Requests, r => FakeHttpMessageHandler.Is(r, "PATCH", "/revoke")); + } + + [Fact] + public async Task Revoke_WithBlankOrgNameConfigured_LogsTheFailureBeforeThrowing() + { + // Regression test: Revoke()'s catch block used to rethrow a wrapped exception with no + // _logger call at all - every other terminating path in this class (Enroll, GetSingleRecord, + // Synchronize, Ping) logs on exception, but a rejected Revoke left zero trace in the plugin's + // own logs that the attempt was ever made or why it was refused. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + + var handler = BaseHandler(); + var plugin = new MarkMonitorCAPlugin(handler.BuildClient()); + var configProvider = FakeAnyCAPluginConfigProvider.WithDefaults(); + configProvider.CAConnectionData[MarkMonitorCAPluginConfig.ConfigConstants.OrgName] = ""; + plugin.Initialize(configProvider, new FakeCertificateDataReader()); + + await Assert.ThrowsAsync(() => plugin.Revoke(OrderId, "aabbcc", 0)); + + Assert.Contains(capturingFactory.Messages, + m => m.Contains("Revoke failed", StringComparison.OrdinalIgnoreCase) && + m.Contains(OrderId, StringComparison.Ordinal)); + } + + [Fact] + public async Task Revoke_WithOrderIdContainingCrLf_SanitizesItInLogOutput() + { + // Regression test (CWE-117): Revoke() used to log the caller-supplied orderId/ + // hexSerialNumber verbatim before the GUID/format validation performed deeper inside + // MarkMonitorClient ever ran, letting an embedded CR/LF forge a fake log line. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + + const string maliciousOrderId = "not-a-guid\r\n2026-08-10 09:00:00 [INF] FAKE forged log line"; + var handler = BaseHandler(); + var plugin = new MarkMonitorCAPlugin(handler.BuildClient()); + plugin.Initialize(FakeAnyCAPluginConfigProvider.WithDefaults(), new FakeCertificateDataReader()); + + await Assert.ThrowsAsync(() => plugin.Revoke(maliciousOrderId, "aabbcc", 0)); + + Assert.DoesNotContain(capturingFactory.Messages, m => m.Contains("\r\n", StringComparison.Ordinal)); + Assert.Contains(capturingFactory.Messages, m => m.Contains("\\r\\n", StringComparison.Ordinal)); + } +} diff --git a/markmonitor-caplugin.Tests/MarkMonitorCAPluginValidateConnectionInfoTests.cs b/markmonitor-caplugin.Tests/MarkMonitorCAPluginValidateConnectionInfoTests.cs new file mode 100644 index 0000000..728db08 --- /dev/null +++ b/markmonitor-caplugin.Tests/MarkMonitorCAPluginValidateConnectionInfoTests.cs @@ -0,0 +1,197 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests; + +[Collection(LogHandlerFactoryCollection.Name)] +public class MarkMonitorCAPluginValidateConnectionInfoTests +{ + private static Dictionary ValidConnectionInfo(string baseUrl = "https://api.markmonitor.com") => + new() + { + [MarkMonitorCAPluginConfig.ConfigConstants.ApiKey] = "key", + [MarkMonitorCAPluginConfig.ConfigConstants.ApiUsername] = "user", + [MarkMonitorCAPluginConfig.ConfigConstants.ApiPassword] = "pass", + [MarkMonitorCAPluginConfig.ConfigConstants.BaseUrl] = baseUrl, + [MarkMonitorCAPluginConfig.ConfigConstants.OrgName] = "Test Org" + }; + + [Fact] + public async Task ValidateCAConnectionInfo_WithHttpBaseUrl_Throws() + { + // Fails the aggregated field checks before ever attempting a live call, so no client + // injection is needed here. + var plugin = new MarkMonitorCAPlugin(); + + var ex = await Assert.ThrowsAsync(() => + plugin.ValidateCAConnectionInfo(ValidConnectionInfo("http://mm-api.internal"))); + + Assert.Contains("https://", ex.Message); + } + + [Theory] + [InlineData("https://api.markmonitor.com")] + [InlineData("HTTPS://api.markmonitor.com")] + public async Task ValidateCAConnectionInfo_WithHttpsBaseUrlAndWorkingCredentials_DoesNotThrow(string baseUrl) + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, + SampleOrgs.OrgsListResponse(SampleOrgs.OrgWithContact()))); + var plugin = new MarkMonitorCAPlugin(handler.BuildClient()); + + await plugin.ValidateCAConnectionInfo(ValidConnectionInfo(baseUrl)); + } + + [Fact] + public async Task ValidateCAConnectionInfo_WhenAuthenticationFails_ThrowsASanitizedErrorWithoutTheRawResponse() + { + var handler = new FakeHttpMessageHandler() + .When(req => FakeHttpMessageHandler.Is(req, "POST", "/auth/v1/auth/authenticate"), + FakeHttpMessageHandler.Json(HttpStatusCode.Unauthorized, + """{"errors":[{"code":"auth.invalidCredentials","message":"Invalid API key or credentials - secret-token-xyz"}]}""")); + var plugin = new MarkMonitorCAPlugin(handler.BuildClient()); + + var ex = await Assert.ThrowsAsync(() => + plugin.ValidateCAConnectionInfo(ValidConnectionInfo())); + + Assert.Contains("Authentication failed", ex.Message); + Assert.DoesNotContain("secret-token-xyz", ex.Message); + Assert.DoesNotContain("auth.invalidCredentials", ex.Message); + } + + [Fact] + public async Task ValidateCAConnectionInfo_WhenListingOrganizationsFails_ThrowsASanitizedError() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.InternalServerError, + """{"errors":[{"code":"request.genericError","message":"An unexpected error occurred."}]}""")); + var plugin = new MarkMonitorCAPlugin(handler.BuildClient()); + + var ex = await Assert.ThrowsAsync(() => + plugin.ValidateCAConnectionInfo(ValidConnectionInfo())); + + Assert.Contains("listing organizations failed", ex.Message); + Assert.DoesNotContain("An unexpected error occurred", ex.Message); + } + + [Fact] + public async Task ValidateCAConnectionInfo_WithTimeoutSecondsZero_ThrowsASanitizedErrorRatherThanCrashing() + { + // Regression test: TimeoutSeconds=0 used to reach HttpClient.Timeout's own setter unguarded, + // which .NET throws ArgumentOutOfRangeException for - propagating as a raw unhandled + // exception instead of this method's designed sanitized AnyCAValidationException. No client + // is injected here on purpose, since that's the only path that reaches the real + // (non-test-seam) transient-client construction where TimeoutSeconds actually gets used. An + // unroutable address (a closed local port) makes the live call fail fast and predictably; + // what matters is which exception type surfaces, not why the call failed. + var plugin = new MarkMonitorCAPlugin(); + var connectionInfo = ValidConnectionInfo("https://127.0.0.1:1"); + connectionInfo[MarkMonitorCAPluginConfig.ConfigConstants.TimeoutSeconds] = 0; + + var ex = await Assert.ThrowsAsync(() => + plugin.ValidateCAConnectionInfo(connectionInfo)); + + Assert.Contains("Authentication failed", ex.Message); + } + + [Fact] + public async Task ValidateCAConnectionInfo_WhenNoOrganizationsAreVisible_ThrowsASanitizedError() + { + var handler = new FakeHttpMessageHandler() + .WithSuccessfulAuth() + .When(req => FakeHttpMessageHandler.Is(req, "GET", "/certs/v1/organization"), + FakeHttpMessageHandler.Json(HttpStatusCode.OK, SampleOrgs.OrgsListResponse())); + var plugin = new MarkMonitorCAPlugin(handler.BuildClient()); + + var ex = await Assert.ThrowsAsync(() => + plugin.ValidateCAConnectionInfo(ValidConnectionInfo())); + + Assert.Contains("listing organizations failed", ex.Message); + } + + [Fact] + public async Task ValidateCAConnectionInfo_WithAMalformedNumericField_ThrowsASanitizedErrorRatherThanCrashing() + { + // Regression test: the connectionInfo-to-MarkMonitorConfig deserialization used to sit + // outside both inner try/catch blocks, so a non-numeric value for one of the Number-typed + // fields threw a raw, unlogged JsonSerializationException instead of this method's designed + // sanitized AnyCAValidationException. No client is injected here on purpose, since that's + // the only path that reaches the real (non-test-seam) deserialization. + var plugin = new MarkMonitorCAPlugin(); + var connectionInfo = ValidConnectionInfo(); + connectionInfo[MarkMonitorCAPluginConfig.ConfigConstants.PageSize] = "not-a-number"; + + var ex = await Assert.ThrowsAsync(() => + plugin.ValidateCAConnectionInfo(connectionInfo)); + + Assert.Contains("could not be parsed", ex.Message); + } + + [Fact] + public async Task ValidateCAConnectionInfo_WithAMalformedNumericFieldContainingCrlf_DoesNotForgeALogLine() + { + // Regression test: the deserialization-failure catch block logged the raw + // JsonSerializationException message unsanitized - that message echoes back the rejected + // value verbatim, so an embedded CR/LF in a submitted field could forge a fake log line + // (CWE-117), the exact class of input every other caller-supplied value in this codebase is + // sanitized against before logging. + using var _ = CapturingLoggerFactory.Install(out var capturingFactory); + var plugin = new MarkMonitorCAPlugin(); + var connectionInfo = ValidConnectionInfo(); + connectionInfo[MarkMonitorCAPluginConfig.ConfigConstants.PageSize] = "bad\r\nFAKE LOG LINE: admin logged in"; + + await Assert.ThrowsAsync(() => plugin.ValidateCAConnectionInfo(connectionInfo)); + + Assert.DoesNotContain(capturingFactory.Entries, + e => e.Message.Contains('\n') || e.Message.Contains('\r')); + Assert.Contains(capturingFactory.Entries, + e => e.Level == LogLevel.Error && e.Message.Contains("FAKE LOG LINE", StringComparison.Ordinal)); + } + + [Fact] + public async Task ValidateCAConnectionInfo_WithEnabledFalse_SkipsTheLiveConnectivityCheck() + { + // Regression test: Enabled's own documented purpose is letting an admin save the CA + // connector before real MarkMonitor credentials are available. The new live-connectivity + // check used to run unconditionally, breaking that pre-existing, documented workflow for a + // connector saved with placeholder credentials while disabled. No client is injected and an + // unroutable address is used on purpose - the assertion is that no live call is even + // attempted, not that one succeeds. + var plugin = new MarkMonitorCAPlugin(); + var connectionInfo = ValidConnectionInfo("https://127.0.0.1:1"); + connectionInfo[MarkMonitorCAPluginConfig.ConfigConstants.Enabled] = false; + + await plugin.ValidateCAConnectionInfo(connectionInfo); + } + + [Fact] + public async Task ValidateCAConnectionInfo_WithEnabledTrueOrAbsent_StillPerformsTheLiveConnectivityCheck() + { + var plugin = new MarkMonitorCAPlugin(); + var connectionInfo = ValidConnectionInfo("https://127.0.0.1:1"); + connectionInfo[MarkMonitorCAPluginConfig.ConfigConstants.Enabled] = true; + + await Assert.ThrowsAsync(() => + plugin.ValidateCAConnectionInfo(connectionInfo)); + } +} diff --git a/markmonitor-caplugin.Tests/MarkMonitorCAPluginValidateProductInfoTests.cs b/markmonitor-caplugin.Tests/MarkMonitorCAPluginValidateProductInfoTests.cs new file mode 100644 index 0000000..120bedf --- /dev/null +++ b/markmonitor-caplugin.Tests/MarkMonitorCAPluginValidateProductInfoTests.cs @@ -0,0 +1,71 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Keyfactor.AnyGateway.Extensions; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests; + +public class MarkMonitorCAPluginValidateProductInfoTests +{ + [Theory] + [InlineData("SslDvGeotrust")] + [InlineData("SslOvBasic")] + [InlineData("SslEvSecuresitePro")] + public async Task ValidateProductInfo_WithAValidProductId_DoesNotThrow(string productId) + { + var plugin = new MarkMonitorCAPlugin(); + var productInfo = new EnrollmentProductInfo { ProductID = productId }; + + await plugin.ValidateProductInfo(productInfo, new Dictionary()); + } + + [Fact] + public async Task ValidateProductInfo_WithAnInvalidProductId_ThrowsAndListsTheValidValues() + { + var plugin = new MarkMonitorCAPlugin(); + var productInfo = new EnrollmentProductInfo { ProductID = "NotARealProduct" }; + + var ex = await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(productInfo, new Dictionary())); + + Assert.Contains("NotARealProduct", ex.Message); + Assert.Contains("SslDvGeotrust", ex.Message); + } + + [Fact] + public async Task ValidateProductInfo_WithAnEmptyProductId_Throws() + { + var plugin = new MarkMonitorCAPlugin(); + var productInfo = new EnrollmentProductInfo { ProductID = "" }; + + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(productInfo, new Dictionary())); + } + + [Fact] + public async Task ValidateProductInfo_WithANumericStringForAnUndefinedEnumValue_Throws() + { + // Regression test: Enum.TryParse alone "succeeds" for any numeric string + // that fits the underlying int type, even with no member defined for that value (CertOrderTypes + // has 12 members, values 0-11) - Enum.IsDefined is the check that actually enforces membership. + var plugin = new MarkMonitorCAPlugin(); + var productInfo = new EnrollmentProductInfo { ProductID = "20" }; + + var ex = await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(productInfo, new Dictionary())); + + Assert.Contains("20", ex.Message); + Assert.Contains("SslDvGeotrust", ex.Message); + } +} diff --git a/markmonitor-caplugin.Tests/MarkMonitorConfigTests.cs b/markmonitor-caplugin.Tests/MarkMonitorConfigTests.cs new file mode 100644 index 0000000..e4e11c3 --- /dev/null +++ b/markmonitor-caplugin.Tests/MarkMonitorConfigTests.cs @@ -0,0 +1,100 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests; + +public class MarkMonitorConfigTests +{ + [Fact] + public void PageSize_DefaultsTo100() + { + Assert.Equal(100, new MarkMonitorConfig().PageSize); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-5, 1)] + [InlineData(1, 1)] + [InlineData(250, 250)] + [InlineData(500, 500)] + [InlineData(501, 500)] + [InlineData(10000, 500)] + public void PageSize_ClampsOutOfRangeValues(int assigned, int expected) + { + var config = new MarkMonitorConfig { PageSize = assigned }; + + Assert.Equal(expected, config.PageSize); + } + + [Fact] + public void TimeoutSeconds_DefaultsTo120() + { + Assert.Equal(120, new MarkMonitorConfig().TimeoutSeconds); + } + + [Theory] + [InlineData(0, 1)] // 0 would otherwise crash HttpClient.Timeout's own setter + [InlineData(-5, 1)] + [InlineData(1, 1)] + [InlineData(60, 60)] + [InlineData(120, 120)] + [InlineData(121, 120)] // never allowed above this field's own pre-existing hardcoded default + [InlineData(10000, 120)] + public void TimeoutSeconds_ClampsOutOfRangeValues(int assigned, int expected) + { + var config = new MarkMonitorConfig { TimeoutSeconds = assigned }; + + Assert.Equal(expected, config.TimeoutSeconds); + } + + [Fact] + public void PickupRetries_DefaultsTo5() + { + Assert.Equal(5, new MarkMonitorConfig().PickupRetries); + } + + [Theory] + [InlineData(0, 0)] // 0 is a valid, deliberate "disable polling" value + [InlineData(-5, 0)] + [InlineData(5, 5)] + [InlineData(20, 20)] + [InlineData(21, 20)] + [InlineData(10000, 20)] + public void PickupRetries_ClampsOutOfRangeValues(int assigned, int expected) + { + var config = new MarkMonitorConfig { PickupRetries = assigned }; + + Assert.Equal(expected, config.PickupRetries); + } + + [Fact] + public void PickupDelaySeconds_DefaultsTo10() + { + Assert.Equal(10, new MarkMonitorConfig().PickupDelaySeconds); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(-5, 0)] + [InlineData(10, 10)] + [InlineData(60, 60)] + [InlineData(61, 60)] + [InlineData(10000, 60)] + public void PickupDelaySeconds_ClampsOutOfRangeValues(int assigned, int expected) + { + var config = new MarkMonitorConfig { PickupDelaySeconds = assigned }; + + Assert.Equal(expected, config.PickupDelaySeconds); + } +} diff --git a/markmonitor-caplugin.Tests/Models/TokenResponseTests.cs b/markmonitor-caplugin.Tests/Models/TokenResponseTests.cs new file mode 100644 index 0000000..6dbc25f --- /dev/null +++ b/markmonitor-caplugin.Tests/Models/TokenResponseTests.cs @@ -0,0 +1,37 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; +using Newtonsoft.Json; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.Models; + +public class TokenResponseTests +{ + [Fact] + public void Deserialize_RealApiShape_PopulatesExpiresIn() + { + // The real MarkMonitor /auth/v1/auth/authenticate response (confirmed against the live + // sandbox) uses "expiresIn" (camelCase), not "expires_in". A mismatch here means ExpiresIn + // silently deserializes to 0, which makes every token look instantly expired to any code + // (like MarkMonitorClient's expiry tracking) that relies on it. + const string json = """{"token":"abc123","expiresIn":3600}"""; + + var result = JsonConvert.DeserializeObject(json); + + Assert.NotNull(result); + Assert.Equal("abc123", result.BearerToken); + Assert.Equal(3600, result.ExpiresIn); + } +} diff --git a/markmonitor-caplugin.Tests/README.md b/markmonitor-caplugin.Tests/README.md new file mode 100644 index 0000000..22dd426 --- /dev/null +++ b/markmonitor-caplugin.Tests/README.md @@ -0,0 +1,13 @@ +# markmonitor-caplugin.Tests + +xUnit unit-test suite for the MarkMonitor CA plugin. Exercises `MarkMonitorCAPlugin` and +`MarkMonitorClient` against a fake `HttpMessageHandler` (see `TestHelpers/FakeHttpMessageHandler.cs`) +and injected fakes (`FakeCertificateDataReader`, `FakeAnyCAPluginConfigProvider`, +`ManualTimeProvider`) - no live API or credentials required, safe to run in CI. + +```shell +dotnet test markmonitor-caplugin.sln -c Release +``` + +See [DEVELOPMENT.md](../DEVELOPMENT.md#unit-tests) for what the suite covers and the pattern to +follow when adding new tests. diff --git a/markmonitor-caplugin.Tests/TestHelpers/CapturingLoggerFactory.cs b/markmonitor-caplugin.Tests/TestHelpers/CapturingLoggerFactory.cs new file mode 100644 index 0000000..0f191f3 --- /dev/null +++ b/markmonitor-caplugin.Tests/TestHelpers/CapturingLoggerFactory.cs @@ -0,0 +1,76 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Concurrent; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +/// A minimal ILoggerFactory that records every formatted log message it's given, so tests +/// can assert on log output emitted via Keyfactor.Logging's LogHandler (which only exposes a +/// settable Factory, not the concrete Microsoft.Extensions.Logging.LoggerFactory type). +public sealed class CapturingLoggerFactory : ILoggerFactory +{ + /// Points LogHandler.Factory at a fresh CapturingLoggerFactory and returns an + /// IDisposable that restores it to a NullLoggerFactory - use with a `using` statement so a test + /// doesn't need its own try/finally around the swap. + public static IDisposable Install(out CapturingLoggerFactory factory) + { + factory = new CapturingLoggerFactory(); + LogHandler.Factory = factory; + return new Restorer(); + } + + private sealed class Restorer : IDisposable + { + public void Dispose() => LogHandler.Factory = new NullLoggerFactory(); + } + + public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new(); + + /// Formatted message text only, for the (more common) callers that don't need to filter + /// by level. + public IEnumerable Messages => Entries.Select(e => e.Message); + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries); + + public void AddProvider(ILoggerProvider provider) + { + } + + public void Dispose() + { + } + + private sealed class CapturingLogger(ConcurrentQueue<(LogLevel Level, string Message)> entries) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => NoopScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => + entries.Enqueue((logLevel, formatter(state, exception))); + + private sealed class NoopScope : IDisposable + { + public static readonly NoopScope Instance = new(); + public void Dispose() + { + } + } + } +} diff --git a/markmonitor-caplugin.Tests/TestHelpers/FakeAnyCAPluginConfigProvider.cs b/markmonitor-caplugin.Tests/TestHelpers/FakeAnyCAPluginConfigProvider.cs new file mode 100644 index 0000000..27374a5 --- /dev/null +++ b/markmonitor-caplugin.Tests/TestHelpers/FakeAnyCAPluginConfigProvider.cs @@ -0,0 +1,40 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Keyfactor.AnyGateway.Extensions; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +public class FakeAnyCAPluginConfigProvider : IAnyCAPluginConfigProvider +{ + public Dictionary CAConnectionData { get; set; } = new(); + + public static FakeAnyCAPluginConfigProvider WithDefaults(string baseUrl = "https://api.markmonitor.test") => + new() + { + CAConnectionData = new Dictionary + { + [MarkMonitorCAPluginConfig.ConfigConstants.ApiKey] = "test-api-key", + [MarkMonitorCAPluginConfig.ConfigConstants.ApiUsername] = "test-user", + [MarkMonitorCAPluginConfig.ConfigConstants.ApiPassword] = "test-password", + [MarkMonitorCAPluginConfig.ConfigConstants.BaseUrl] = baseUrl, + [MarkMonitorCAPluginConfig.ConfigConstants.OrgName] = "Test Org", + [MarkMonitorCAPluginConfig.ConfigConstants.Enabled] = true, + // 0 disables Enroll's post-submit issuance polling by default here - tests that + // specifically exercise polling opt in explicitly rather than every other test needing + // to stub a GET /certs/v1/order/{id} route it doesn't otherwise care about. + [MarkMonitorCAPluginConfig.ConfigConstants.PickupRetries] = 0 + } + }; +} diff --git a/markmonitor-caplugin.Tests/TestHelpers/FakeCertificateDataReader.cs b/markmonitor-caplugin.Tests/TestHelpers/FakeCertificateDataReader.cs new file mode 100644 index 0000000..d7712b9 --- /dev/null +++ b/markmonitor-caplugin.Tests/TestHelpers/FakeCertificateDataReader.cs @@ -0,0 +1,49 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Keyfactor.AnyGateway.Extensions; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +public class FakeCertificateDataReader : ICertificateDataReader +{ + public Dictionary SerialNumberToRequestId { get; } = new(); + public Dictionary RequestIdToStatus { get; } = new(); + + /// Request IDs for which a lookup call throws, simulating a downstream failure (e.g. a + /// transient database error) for one specific record during a sync. + public HashSet ThrowForRequestIds { get; } = new(); + + public Task GetStatusByRequestID(string caRequestID) + { + if (ThrowForRequestIds.Contains(caRequestID)) + throw new Exception($"Simulated lookup failure for {caRequestID}"); + return Task.FromResult(RequestIdToStatus.GetValueOrDefault(caRequestID, 0)); + } + + public Task DoesCertExistForRequestID(string caRequestID) + { + if (ThrowForRequestIds.Contains(caRequestID)) + throw new Exception($"Simulated lookup failure for {caRequestID}"); + return Task.FromResult(RequestIdToStatus.ContainsKey(caRequestID)); + } + + public Task GetRequestIDBySerialNumber(string serialNumber) => + Task.FromResult(SerialNumberToRequestId.GetValueOrDefault(serialNumber, string.Empty)); + + public Dictionary ExpirationDateByRequestId { get; } = new(); + + public DateTime? GetExpirationDateByRequestId(string caRequestID) => + ExpirationDateByRequestId.TryGetValue(caRequestID, out var expiration) ? expiration : null; +} diff --git a/markmonitor-caplugin.Tests/TestHelpers/FakeHttpMessageHandler.cs b/markmonitor-caplugin.Tests/TestHelpers/FakeHttpMessageHandler.cs new file mode 100644 index 0000000..cb7840f --- /dev/null +++ b/markmonitor-caplugin.Tests/TestHelpers/FakeHttpMessageHandler.cs @@ -0,0 +1,125 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using System.Net.Http; +using System.Text; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +/// +/// A minimal routable fake HttpMessageHandler for exercising MarkMonitorClient against canned +/// responses instead of the live MarkMonitor API. Register routes with When(), most-specific first; +/// each route's responses are consumed in order and the last one registered repeats indefinitely. +/// +public sealed class FakeHttpMessageHandler : HttpMessageHandler +{ + private sealed class Route + { + public required Func Matches; + public required Queue>> Responses; + } + + private readonly object _lock = new(); + private readonly List _routes = new(); + public List Requests { get; } = new(); + + public FakeHttpMessageHandler When(Func matches, params HttpResponseMessage[] responses) + { + return WhenAsync(matches, + responses.Select(r => (Func>)(_ => Task.FromResult(r))) + .ToArray()); + } + + /// Like When(), but the response is produced asynchronously - e.g. via WhenGated() to + /// hold a response open until a test explicitly releases it, for exercising genuine + /// concurrency/in-flight-request behavior. + public FakeHttpMessageHandler WhenAsync(Func matches, + params Func>[] responseFactories) + { + var route = new Route + { + Matches = matches, + Responses = new Queue>>(responseFactories) + }; + lock (_lock) + { + _routes.Add(route); + } + return this; + } + + /// Registers a response that isn't returned until `gate` completes, so a test can hold + /// a request "in flight" for as long as it needs before releasing the response. + public FakeHttpMessageHandler WhenGated(Func matches, Task gate, + HttpResponseMessage response) => + WhenAsync(matches, async _ => + { + await gate; + return response; + }); + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + Func> factory; + lock (_lock) + { + Requests.Add(request); + var route = _routes.LastOrDefault(r => r.Matches(request) && r.Responses.Count > 0) + ?? _routes.LastOrDefault(r => r.Matches(request)); + + if (route == null) + throw new InvalidOperationException($"No fake response registered for {request.Method} {request.RequestUri}"); + + factory = route.Responses.Count > 1 ? route.Responses.Dequeue() : route.Responses.Peek(); + } + + // A real HttpMessageHandler observes the cancellation token itself - honor it here too, so a + // test can exercise a caller's cancellation-propagation behavior against a route (e.g. one + // registered via WhenGated) that would otherwise hang forever. + var responseTask = factory(request); + var cancellationTask = Task.Delay(Timeout.Infinite, cancellationToken); + var completed = await Task.WhenAny(responseTask, cancellationTask); + if (completed == cancellationTask) + throw new TaskCanceledException("The fake request was cancelled.", null, cancellationToken); + return await responseTask; + } + + public static bool Is(HttpRequestMessage req, string method, string pathFragment) => + req.Method.Method.Equals(method, StringComparison.OrdinalIgnoreCase) && + (req.RequestUri?.ToString().Contains(pathFragment, StringComparison.OrdinalIgnoreCase) ?? false); + + public static HttpResponseMessage Json(HttpStatusCode status, string json) => + new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + + /// Wires up a successful /auth/v1/auth/authenticate response so callers can focus tests + /// on the endpoint(s) they actually care about. + public FakeHttpMessageHandler WithSuccessfulAuth(string token = "fake-token", int expiresIn = 3600) + { + return When(req => Is(req, "POST", "/auth/v1/auth/authenticate"), + Json(HttpStatusCode.OK, $"{{\"token\":\"{token}\",\"expiresIn\":{expiresIn}}}")); + } + + /// Builds a MarkMonitorClient wired to this fake handler, using the same + /// base URL/credentials every test uses since they're never actually sent anywhere real. Retry + /// backoff delays are instant by default (no test wants a multi-second real sleep just because a + /// route happens to return a 5xx/429/network failure) - pass `delay` to observe or slow down the + /// schedule a test actually cares about verifying. + public MarkMonitorClient BuildClient(TimeProvider? timeProvider = null, + Func? delay = null) => + new("https://api.markmonitor.test", "key", "user", "pass", true, this, timeProvider, + delay: delay ?? ((_, _) => Task.CompletedTask)); +} diff --git a/markmonitor-caplugin.Tests/TestHelpers/LogHandlerFactoryCollection.cs b/markmonitor-caplugin.Tests/TestHelpers/LogHandlerFactoryCollection.cs new file mode 100644 index 0000000..46608bd --- /dev/null +++ b/markmonitor-caplugin.Tests/TestHelpers/LogHandlerFactoryCollection.cs @@ -0,0 +1,28 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +/// +/// Keyfactor.Logging's LogHandler.Factory is a single process-wide static. xUnit runs different test +/// classes concurrently by default, so any two test classes that both reassign it (to observe log +/// output via a fake ILoggerFactory) can race and clobber each other's factory mid-test. Every test +/// class that touches LogHandler.Factory declares [Collection(Name)] so they're all serialized against +/// one another instead of running in parallel. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class LogHandlerFactoryCollection +{ + public const string Name = "LogHandler.Factory"; +} diff --git a/markmonitor-caplugin.Tests/TestHelpers/ManualTimeProvider.cs b/markmonitor-caplugin.Tests/TestHelpers/ManualTimeProvider.cs new file mode 100644 index 0000000..a0a203b --- /dev/null +++ b/markmonitor-caplugin.Tests/TestHelpers/ManualTimeProvider.cs @@ -0,0 +1,23 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +/// A TimeProvider with a settable clock, for deterministically testing expiry logic. +public class ManualTimeProvider : TimeProvider +{ + public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow; + + public override DateTimeOffset GetUtcNow() => UtcNow; +} diff --git a/markmonitor-caplugin.Tests/TestHelpers/SampleConfig.cs b/markmonitor-caplugin.Tests/TestHelpers/SampleConfig.cs new file mode 100644 index 0000000..0142930 --- /dev/null +++ b/markmonitor-caplugin.Tests/TestHelpers/SampleConfig.cs @@ -0,0 +1,35 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +/// Builds a MarkMonitorConfig matching the fake handler's base URL/credentials. +public static class SampleConfig +{ + public static MarkMonitorConfig Default(string orgName = "Test Org") => new() + { + BaseUrl = "https://api.markmonitor.test", + ApiKey = "key", + ApiUsername = "user", + ApiPassword = "pass", + OrgName = orgName, + Enabled = true, + // 0 disables Enroll's post-submit issuance polling by default here - tests that specifically + // exercise polling opt in explicitly rather than every other enroll test needing to stub a + // GET /certs/v1/order/{id} route it doesn't otherwise care about. + PickupRetries = 0 + }; +} diff --git a/markmonitor-caplugin.Tests/TestHelpers/SampleCsr.cs b/markmonitor-caplugin.Tests/TestHelpers/SampleCsr.cs new file mode 100644 index 0000000..7033512 --- /dev/null +++ b/markmonitor-caplugin.Tests/TestHelpers/SampleCsr.cs @@ -0,0 +1,37 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +/// A fixed, valid RSA CSR (CN=test.mmcertdomain.com) for enrollment tests. +public static class SampleCsr +{ + public const string Pem = """ + -----BEGIN CERTIFICATE REQUEST----- + MIICZTCCAU0CAQAwIDEeMBwGA1UEAwwVdGVzdC5tbWNlcnRkb21haW4uY29tMIIB + IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAne7uDpjrruy7z2sv+RKbSW4U + erEOKO8DLd2H4/6x0NzWW0sMEZ9Z/vHx1JYgYHjoS1UIHPg30tBtL6/CZPuC3j00 + bcWbyBhEosrcfOb7xX5lKScNO3qvlZE9EGcY/kCl2m/2kvn0Jric8WIar8zbxe8f + A7VUA4DcGZUZTNfrmEGw3xVKvEOZQBH7OIUsSKSsdifzcldiwvRb2xiCcMd8hLp3 + XGMEU+9o6pZM8PYU3SWT1KPAsp29Uef1l4u4e4SreerIoNV1NgmCShpj8zf0lHp+ + JlocXOzxicf+2njnzBYYEXWkDoPBGLzKT6ShJR2MejpKMO8qviLfGP2jIN427QID + AQABoAAwDQYJKoZIhvcNAQELBQADggEBAGHPpJi5OqAnDmIJ3+i2HMeObiCddax0 + hBWeoEje2B2o2M+twsXDtmSUxp5CmZTT4SrJeft9jsH10ZG5cd7ypMR3SKYMBmAP + n3a8xGOQKODOaO1KUbZyJ4fxePXHHw6QrTx9AKrWEV9Y19K8kIgdnafqRiyqJ10r + uyKih/NvUzC0ETWXCCcGYP5BI2S+kEnT5r9osqYlTMTJwGiTuoutUkW1VB/o7SU1 + 8A0FAvkClJGY5DNOJ5AMTKdD6E5X+09b9YuDUAhqg+ivYPCBsluYDS3Ayc0qZpCD + OjqDSP9B1HX7+OIZRgHNQ7aiHocPlzqBzcW9M+byDjbL6sZHJtKf6mQ= + -----END CERTIFICATE REQUEST----- + """; +} diff --git a/markmonitor-caplugin.Tests/TestHelpers/SampleCsr2.cs b/markmonitor-caplugin.Tests/TestHelpers/SampleCsr2.cs new file mode 100644 index 0000000..87814fd --- /dev/null +++ b/markmonitor-caplugin.Tests/TestHelpers/SampleCsr2.cs @@ -0,0 +1,38 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +/// A second, distinct valid RSA CSR (CN=other.mmcertdomain.com) for tests that need two +/// different CSRs, e.g. to confirm enrollment idempotency is scoped correctly. +public static class SampleCsr2 +{ + public const string Pem = """ + -----BEGIN CERTIFICATE REQUEST----- + MIICZjCCAU4CAQAwITEfMB0GA1UEAwwWb3RoZXIubW1jZXJ0ZG9tYWluLmNvbTCC + ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKbI0/Flsz3CyIIVQlVd95HM + MG1iVSLTsC+TDmIP08UQB8EWQu7gmC4fbKu3ip5dtKEn7hVMVveQohNMxbFNPZKv + 6TtmjizB9JD4sx/JpFNr0UJg9I55b09dZ/XeeewemUO+fodLhBxsEz0aU9tjqxkh + RXs4Ts84jhtXZ597yzsxPxzqMif1+ARh2uq9dOok/FCRNXisz9yI+WWOW89BiS3e + QnOVnrsh6eX7xwDbP4rz8nTf7g1daY3INruluT0g2gdJGPTThIu3haocnUTtEjkn + 2Qn4f0Wxg/GXckR1igNYHKIew927p7SxIqxZnWMH4L/YkAuguG3RwvWA+VzOnWUC + AwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQBTd2VIhGxwRpfS7mXiU3pumvY6TLib + dw18ALi57VFxo95ZAyIdD+t68ISt6sFF5e7TTV6Fy9uqpnwFGCYS9yXdbS7/VDjj + Xvz2JmZO1q+mYT85/bahvRLvX0mlZhViucu4ryUy5BgUEVyrLk4QiLn9U1fgiA87 + l/2NVswRTovqsK6pFrcsVT1nqqkbywDKgBXQD5RB8WFPeKpM+qlz4R/yx6jvlJ3a + 5Sa8oP/kr//U+chmQiTopPFA20Hx/uKD+geO02qgb+ThdHMnJp+QeZ1lkdMrxkLP + CvrDLqOiLH1B2+4ImzBVbg2UzcnolwSeeQUR78bUNhxE/Pifn6lFgyrc + -----END CERTIFICATE REQUEST----- + """; +} diff --git a/markmonitor-caplugin.Tests/TestHelpers/SampleCsrWithSans.cs b/markmonitor-caplugin.Tests/TestHelpers/SampleCsrWithSans.cs new file mode 100644 index 0000000..e06dbee --- /dev/null +++ b/markmonitor-caplugin.Tests/TestHelpers/SampleCsrWithSans.cs @@ -0,0 +1,56 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Pkcs; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +/// Generates a fresh RSA CSR with a CSR-embedded SAN (subjectAltName) extension request, +/// for tests exercising the union of dictionary-supplied and CSR-embedded SANs. Unlike +/// 's fixed PEM, this is generated on the fly since the fixed fixtures +/// predate SAN support and none of them carry an extension request. +public static class SampleCsrWithSans +{ + public static string GeneratePem(string commonName, params string[] dnsNames) + { + var keyPairGen = new RsaKeyPairGenerator(); + keyPairGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyPairGen.GenerateKeyPair(); + + var subject = new X509Name($"CN={commonName}"); + var generalNames = new GeneralNames(dnsNames.Select(d => new GeneralName(GeneralName.DnsName, d)).ToArray()); + var extensions = new X509Extensions(new Dictionary + { + [X509Extensions.SubjectAlternativeName] = new X509Extension(false, new DerOctetString(generalNames)) + }); + var attributes = new DerSet(new AttributePkcs(PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, + new DerSet(extensions))); + + var csr = new Pkcs10CertificationRequest("SHA256WITHRSA", subject, keyPair.Public, attributes, + keyPair.Private); + + using var stream = new MemoryStream(); + using var writer = new StreamWriter(stream); + new PemWriter(writer).WriteObject(csr); + writer.Flush(); + return System.Text.Encoding.ASCII.GetString(stream.ToArray()); + } +} diff --git a/markmonitor-caplugin.Tests/TestHelpers/SampleEccCsrs.cs b/markmonitor-caplugin.Tests/TestHelpers/SampleEccCsrs.cs new file mode 100644 index 0000000..849de24 --- /dev/null +++ b/markmonitor-caplugin.Tests/TestHelpers/SampleEccCsrs.cs @@ -0,0 +1,46 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +/// Valid ECC (P-256, CN=test.mmcertdomain.com) CSRs differing only in how the key's curve +/// is encoded - named-curve (OID reference) vs. explicit parameters (prime/coefficients/base point +/// spelled out). MarkMonitor accepts the former and silently fails an order using the latter. +public static class SampleEccCsrs +{ + public const string NamedCurvePem = """ + -----BEGIN CERTIFICATE REQUEST----- + MIHYMIGAAgEAMCAxHjAcBgNVBAMMFXRlc3QubW1jZXJ0ZG9tYWluLmNvbTBZMBMG + ByqGSM49AgEGCCqGSM49AwEHA0IABE1FpjBDQ/1D3WBypTFej+WT8TflM8TfiS5v + wHtWf/ss/6XmNYcUp7y6JO0Hkzzn5bSRNUI4qcn3PtkytIXYwugwCgYIKoZIzj0E + AwIDRwAwRAIgROdwnkYWB1+GHDeLPMnRoP5N2SE0m6BpJQO68851t9cCIHpDUMnt + fPNyziHHK8YmUhMlGfNCUa5IjPgRwA51ByDR + -----END CERTIFICATE REQUEST----- + """; + + public const string ExplicitCurvePem = """ + -----BEGIN CERTIFICATE REQUEST----- + MIIBtjCCAVwCAQAwIDEeMBwGA1UEAwwVdGVzdC5tbWNlcnRkb21haW4uY29tMIIB + MzCB7AYHKoZIzj0CATCB4AIBATAsBgcqhkjOPQEBAiEA/////wAAAAEAAAAAAAAA + AAAAAAD///////////////8wRAQg/////wAAAAEAAAAAAAAAAAAAAAD///////// + //////wEIFrGNdiqOpPns+u9VXaYhrxlHQawzFOw9jvOPD4n0mBLBEEEaxfR8uEs + Qkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpZP40Li/hp/m47n60p8D54WK84zV2sx + Xs7LtkBoN79R9QIhAP////8AAAAA//////////+85vqtpxeehPO5ysL8YyVRAgEB + A0IABEq7nGUCuVXEVLX4xbC3T+mPmMFXSDjpIxscIH5rAMz6Ho6xeXdQroQcn1Am + j3f/2c1XssmutrtIMrgaBox6Tj4wCgYIKoZIzj0EAwIDSAAwRQIgLrjt/SWWn46z + d7J06TvNsE3HXKWK1OLorkJK70daDXICIQCRK/BK5UmBpOLFuenlcIQjSj3B3lbk + 33B/in3asgYtcw== + -----END CERTIFICATE REQUEST----- + """; +} diff --git a/markmonitor-caplugin.Tests/TestHelpers/SampleOrders.cs b/markmonitor-caplugin.Tests/TestHelpers/SampleOrders.cs new file mode 100644 index 0000000..488ea80 --- /dev/null +++ b/markmonitor-caplugin.Tests/TestHelpers/SampleOrders.cs @@ -0,0 +1,79 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +/// Builds minimal-but-valid MarkMonitor order JSON fragments for tests. +public static class SampleOrders +{ + public static string OrderWithCert(string id, string status, string? revokeStatus = null, + string dateValidUntil = "2027-01-01T00:00:00Z", string certType = "SSL_DV_GEOTRUST", + string? organizationId = SampleOrgs.DefaultOrgId) => + $$""" + { + "id": "{{id}}", + "certType": "{{certType}}", + "status": "{{status}}", + "organizationId": {{(organizationId == null ? "null" : $"\"{organizationId}\"")}}, + "cert": { + "commonName": "test.mmcertdomain.com", + "csr": "-----BEGIN CERTIFICATE REQUEST-----\nMII...\n-----END CERTIFICATE REQUEST-----", + "endEntityCert": "-----BEGIN CERTIFICATE-----\nMII...\n-----END CERTIFICATE-----", + "revokeStatus": {{(revokeStatus == null ? "null" : $"\"{revokeStatus}\"")}}, + "dateValidUntil": "{{dateValidUntil}}", + "daysRemaining": 200 + } + } + """; + + public static string OrderWithNullCert(string id, string status) => + $$""" + { + "id": "{{id}}", + "certType": "SSL_DV_GEOTRUST", + "status": "{{status}}", + "cert": null + } + """; + + /// An order whose status has already flipped to issued but whose cert body hasn't been + /// populated yet - the race PollForIssuanceAsync's own completion check (and, if the poll budget + /// exhausts at exactly this moment, EnrollCertificateAsync's own result-consistency check) guards + /// against, as distinct from OrderWithNullCert's "cert is entirely absent" pending state. + public static string OrderIssuedWithoutCertBody(string id) => + $$""" + { + "id": "{{id}}", + "certType": "SSL_DV_GEOTRUST", + "status": "DIGI_ISSUED", + "organizationId": "{{SampleOrgs.DefaultOrgId}}", + "cert": { + "commonName": "test.mmcertdomain.com", + "csr": "-----BEGIN CERTIFICATE REQUEST-----\nMII...\n-----END CERTIFICATE REQUEST-----", + "endEntityCert": null, + "revokeStatus": null, + "dateValidUntil": "2027-01-01T00:00:00Z", + "daysRemaining": 200 + } + } + """; + + public static string OrdersPage(string content, int totalPages = 1) => + $$""" + { + "content": [{{content}}], + "page": { "size": 100, "totalElements": 1, "totalPages": {{totalPages}}, "number": 0 } + } + """; +} diff --git a/markmonitor-caplugin.Tests/TestHelpers/SampleOrgs.cs b/markmonitor-caplugin.Tests/TestHelpers/SampleOrgs.cs new file mode 100644 index 0000000..4dae81d --- /dev/null +++ b/markmonitor-caplugin.Tests/TestHelpers/SampleOrgs.cs @@ -0,0 +1,60 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests.TestHelpers; + +/// Builds minimal-but-valid MarkMonitor organization/contact JSON fragments for tests. +public static class SampleOrgs +{ + public const string DefaultOrgId = "11111111-1111-1111-1111-111111111111"; + public const string DefaultContactId = "22222222-2222-2222-2222-222222222222"; + + public static string OrgWithContact(string orgId = DefaultOrgId, string orgName = "Test Org", + string contactId = DefaultContactId, string contactType = "ORGANIZATION_CONTACT") => + $$""" + { + "id": "{{orgId}}", + "name": "{{orgName}}", + "provider": "DIGICERT", + "providerId": 1, + "contacts": [ + { + "id": "{{contactId}}", + "firstName": "Test", + "lastName": "Contact", + "email": "test.contact@example.com", + "contactTypes": [{ "type": "{{contactType}}" }] + } + ] + } + """; + + public static string OrgsListResponse(string orgJson) => + $$""" + { + "content": [{{orgJson}}], + "page": { "size": 1, "totalElements": 1, "totalPages": 1, "number": 0 } + } + """; + + /// Builds a multi-org list response, for exercising resolution against a search result + /// that contains more than one candidate (e.g. a substring-name false positive). + public static string OrgsListResponse(params string[] orgJsons) => + $$""" + { + "content": [{{string.Join(",", orgJsons)}}], + "page": { "size": {{orgJsons.Length}}, "totalElements": {{orgJsons.Length}}, "totalPages": 1, "number": 0 } + } + """; +} diff --git a/markmonitor-caplugin.Tests/markmonitor-caplugin.Tests.csproj b/markmonitor-caplugin.Tests/markmonitor-caplugin.Tests.csproj new file mode 100644 index 0000000..10c5fa2 --- /dev/null +++ b/markmonitor-caplugin.Tests/markmonitor-caplugin.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + Keyfactor.Extensions.CAPlugin.MarkMonitor.Tests + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/markmonitor-caplugin.sln b/markmonitor-caplugin.sln new file mode 100644 index 0000000..3c599da --- /dev/null +++ b/markmonitor-caplugin.sln @@ -0,0 +1,86 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.31729.503 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "markmonitor-caplugin", "markmonitor-caplugin\markmonitor-caplugin.csproj", "{9D2D6ED9-4626-430C-879D-0FE0FEBED146}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{431498A1-F30A-4307-9FBF-B1D634326444}" + ProjectSection(SolutionItems) = preProject + CHANGELOG.md = CHANGELOG.md + integration-manifest.json = integration-manifest.json + readme_source.md = readme_source.md + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestConsole", "TestConsole\TestConsole.csproj", "{BE76E7C9-7DDE-49CD-8428-8256739BAD93}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "markmonitor-caplugin.Tests", "markmonitor-caplugin.Tests\markmonitor-caplugin.Tests.csproj", "{A07CCBD0-7CC2-4A99-ABC9-41EA20EE4B79}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "markmonitor-caplugin.IntegrationTests", "markmonitor-caplugin.IntegrationTests\markmonitor-caplugin.IntegrationTests.csproj", "{58B15D3E-02C1-43EF-87D3-DF2C546C11D7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Debug|x64.ActiveCfg = Debug|Any CPU + {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Debug|x64.Build.0 = Debug|Any CPU + {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Debug|x86.ActiveCfg = Debug|Any CPU + {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Debug|x86.Build.0 = Debug|Any CPU + {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Release|Any CPU.Build.0 = Release|Any CPU + {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Release|x64.ActiveCfg = Release|Any CPU + {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Release|x64.Build.0 = Release|Any CPU + {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Release|x86.ActiveCfg = Release|Any CPU + {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Release|x86.Build.0 = Release|Any CPU + {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Debug|x64.ActiveCfg = Debug|Any CPU + {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Debug|x64.Build.0 = Debug|Any CPU + {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Debug|x86.ActiveCfg = Debug|Any CPU + {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Debug|x86.Build.0 = Debug|Any CPU + {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Release|Any CPU.Build.0 = Release|Any CPU + {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Release|x64.ActiveCfg = Release|Any CPU + {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Release|x64.Build.0 = Release|Any CPU + {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Release|x86.ActiveCfg = Release|Any CPU + {BE76E7C9-7DDE-49CD-8428-8256739BAD93}.Release|x86.Build.0 = Release|Any CPU + {A07CCBD0-7CC2-4A99-ABC9-41EA20EE4B79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A07CCBD0-7CC2-4A99-ABC9-41EA20EE4B79}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A07CCBD0-7CC2-4A99-ABC9-41EA20EE4B79}.Debug|x64.ActiveCfg = Debug|Any CPU + {A07CCBD0-7CC2-4A99-ABC9-41EA20EE4B79}.Debug|x64.Build.0 = Debug|Any CPU + {A07CCBD0-7CC2-4A99-ABC9-41EA20EE4B79}.Debug|x86.ActiveCfg = Debug|Any CPU + {A07CCBD0-7CC2-4A99-ABC9-41EA20EE4B79}.Debug|x86.Build.0 = Debug|Any CPU + {A07CCBD0-7CC2-4A99-ABC9-41EA20EE4B79}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A07CCBD0-7CC2-4A99-ABC9-41EA20EE4B79}.Release|Any CPU.Build.0 = Release|Any CPU + {A07CCBD0-7CC2-4A99-ABC9-41EA20EE4B79}.Release|x64.ActiveCfg = Release|Any CPU + {A07CCBD0-7CC2-4A99-ABC9-41EA20EE4B79}.Release|x64.Build.0 = Release|Any CPU + {A07CCBD0-7CC2-4A99-ABC9-41EA20EE4B79}.Release|x86.ActiveCfg = Release|Any CPU + {A07CCBD0-7CC2-4A99-ABC9-41EA20EE4B79}.Release|x86.Build.0 = Release|Any CPU + {58B15D3E-02C1-43EF-87D3-DF2C546C11D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {58B15D3E-02C1-43EF-87D3-DF2C546C11D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {58B15D3E-02C1-43EF-87D3-DF2C546C11D7}.Debug|x64.ActiveCfg = Debug|Any CPU + {58B15D3E-02C1-43EF-87D3-DF2C546C11D7}.Debug|x64.Build.0 = Debug|Any CPU + {58B15D3E-02C1-43EF-87D3-DF2C546C11D7}.Debug|x86.ActiveCfg = Debug|Any CPU + {58B15D3E-02C1-43EF-87D3-DF2C546C11D7}.Debug|x86.Build.0 = Debug|Any CPU + {58B15D3E-02C1-43EF-87D3-DF2C546C11D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {58B15D3E-02C1-43EF-87D3-DF2C546C11D7}.Release|Any CPU.Build.0 = Release|Any CPU + {58B15D3E-02C1-43EF-87D3-DF2C546C11D7}.Release|x64.ActiveCfg = Release|Any CPU + {58B15D3E-02C1-43EF-87D3-DF2C546C11D7}.Release|x64.Build.0 = Release|Any CPU + {58B15D3E-02C1-43EF-87D3-DF2C546C11D7}.Release|x86.ActiveCfg = Release|Any CPU + {58B15D3E-02C1-43EF-87D3-DF2C546C11D7}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5D2E21F6-120F-4B71-A596-991879B03943} + EndGlobalSection +EndGlobal diff --git a/markmonitor-caplugin/Client/MarkMonitorClient.cs b/markmonitor-caplugin/Client/MarkMonitorClient.cs new file mode 100644 index 0000000..2e2cd61 --- /dev/null +++ b/markmonitor-caplugin/Client/MarkMonitorClient.cs @@ -0,0 +1,2039 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Text; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; +using Keyfactor.Logging; +using Keyfactor.PKI.Enums.EJBCA; +using Keyfactor.PKI.PEM; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Asn1.X9; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Tls; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; + +public class MarkMonitorClient : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private string _apiKey; + private string _bearerToken; + private DateTime? _tokenExpiresAtUtc; + private string _password; + private string _username; + private readonly TimeProvider _timeProvider; + private readonly SemaphoreSlim _authLock = new(1, 1); + private readonly Func _delay; + private readonly Random _retryJitter = new(); + + // Applies to idempotent GETs, auth, and destructive-but-idempotent PATCH calls + // (cancel/revoke - repeating either is a no-op, unlike a second order-create POST). Deliberately + // NOT used for CreateCertificateOrder's order-create POST - see EnrollCertificateAsync's own + // comments on why retrying that specific call risks creating a duplicate, billable MarkMonitor + // order that the enrollment dedup cache exists to prevent. + private const int MaxRetryAttempts = 3; + private static readonly TimeSpan RetryBaseDelay = TimeSpan.FromSeconds(1); + + // A 429 response's Retry-After is server-controlled input - never trusted beyond this ceiling. + // Uncapped, a single misbehaving/compromised endpoint could dictate how long this process waits + // with no way to interrupt it (SendWithRetryAsync's delay call isn't cancellable on every path, + // e.g. AuthenticateAsync's, which additionally runs inside the shared _authLock). + private const int MaxRetryAfterSeconds = 120; + + // Guards against creating a duplicate MarkMonitor order when Command retries an Enroll call - + // whether the first attempt is still in flight (the retry races it) or already finished but its + // response was lost (timeout, dropped connection, etc). Keyed on org+product+subject+CSR - an + // identical CSR for the same subject/org/product within the window is treated as a retry, not a + // distinct request. The value is reserved (via a TaskCompletionSource) *before* the real + // CreateCertificateOrder call starts, so a retry that arrives while the first call is still + // in-flight awaits the same in-progress result instead of starting a second order. Process-local + // and short-lived by design: it's a guard against the specific narrow retry window, not a durable + // dedup store (that would need to live in MarkMonitor itself). + private static readonly TimeSpan RecentEnrollmentWindow = TimeSpan.FromMinutes(5); + + // Used when resolving an org/group by friendly name (ResolveOrganizationIdAsync, + // ResolveOrganizationAsync, ResolveGroupIdAsync): MarkMonitor's name filter can return several + // fuzzy matches for one configured name, and a small page size would force one sequential HTTP + // round-trip per match just to page through them all before each method's own exact-match filter + // ever runs - this fetches all of them in one request instead. + private const int NameResolutionPageSize = 100; + + private readonly ConcurrentDictionary Tcs)> + _recentEnrollments = new(); + + /// Test-only visibility into how many reservations (successful, failed, or in-flight) + /// are currently held in - used to assert that + /// actually bounds the dictionary's growth over time + /// rather than accumulating one permanent entry per successful enrollment. + internal int RecentEnrollmentsCount => _recentEnrollments.Count; + + /// Test-only visibility into the configured HTTP timeout, to assert that the + /// constructor's timeoutSeconds parameter actually reaches the underlying HttpClient. + internal TimeSpan HttpTimeout => _httpClient.Timeout; + + // A resolved organization/group GUID never changes for a given name - MarkMonitor doesn't let a + // name be reassigned to a different org/group ID - so it's safe to cache indefinitely for this + // client's lifetime (which is itself cached for the whole plugin instance's lifetime). Without + // this, every single Enroll/Revoke/Cancel call re-resolved the configured OrgName (and, for + // enrollment, a named MarkmonitorGroup) via a fresh MarkMonitor API call, even though the + // configured value is invariant for the connector's lifetime - real cost at bulk-operation scale + // (e.g. a CRL/lifecycle sweep revoking thousands of certs). A "not found" result (null) is + // deliberately NOT cached, since an org/group that doesn't exist yet could be created later. + private readonly ConcurrentDictionary _resolvedOrgIdByName = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _resolvedGroupIdByName = new(StringComparer.OrdinalIgnoreCase); + + public MarkMonitorClient(string baseUrl, string apiKey, string username, string password, bool validateSsl = true, + HttpMessageHandler handler = null, TimeProvider timeProvider = null, int timeoutSeconds = 120, + Func delay = null) + { + BaseUrl = baseUrl; + _logger = LogHandler.GetClassLogger(GetType()); + _apiKey = apiKey; + _username = username; + _password = password; + _timeProvider = timeProvider ?? TimeProvider.System; + // Task.Delay's own (TimeSpan, CancellationToken) signature matches this delegate exactly - + // a test can override it to observe/short-circuit backoff waits without a real sleep. + _delay = delay ?? Task.Delay; + + // A caller-supplied handler (e.g. a fake in tests) is used as-is; otherwise build the real + // HttpClientHandler with the usual SSL validation behavior. + if (handler == null) + { + var httpClientHandler = new HttpClientHandler { UseCookies = false }; + + if (!validateSsl) + { + _logger.LogWarning("SSL certificate validation is disabled for {BaseUrl}", baseUrl); + httpClientHandler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true; + } + + handler = httpClientHandler; + } + + _httpClient = new HttpClient(handler); + _httpClient.Timeout = TimeSpan.FromSeconds(timeoutSeconds); + // Every request this client makes wants an "application/json" Accept header, and it never + // changes for the client's lifetime - set it once here rather than in FetchOrderAsync, where + // an Add() on every call (Accept is a collection, not a single-value property) with no + // preceding Remove() appended a fresh duplicate entry per call, unboundedly growing the + // header list on this cached, long-lived HttpClient. + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + public void Dispose() + { + _httpClient.Dispose(); + _authLock.Dispose(); + } + + public string BaseUrl { get; } + + private bool ValidateConfiguration() + { + if (string.IsNullOrEmpty(_apiKey)) + throw new ConfigurationValidationException("API Key is required."); + + if (string.IsNullOrEmpty(_username)) + throw new ConfigurationValidationException("Username is required."); + + if (string.IsNullOrEmpty(_password)) + throw new ConfigurationValidationException("Password is required."); + + return true; + } + + public async Task AuthenticateAsync(string apiKey = null, string username = null, string password = null) + { + _logger.MethodEntry(); + // The entire method body is wrapped in one try/catch, below, so ANY failure - missing config, + // a transport-level failure, a non-2xx response, or a 2xx response with no usable bearer token + // - produces the same single, self-contained, identity-tagged "Authentication failed for + // {Username}" record. Earlier versions logged that only from specific branches (the non-2xx + // response, or a caught HTTP/parse exception), which left gaps for e.g. a config-validation + // failure (thrown before any of those branches even ran) or a well-formed 2xx body missing its + // token field (which wouldn't have thrown at all) - each silently skipping this method's own + // audit record in favor of a generic, identity-less one from whichever caller's catch received + // the exception instead. + try + { + if (!string.IsNullOrEmpty(apiKey)) + { + _logger.LogDebug("Setting API Key"); + _apiKey = apiKey; + } + + if (!string.IsNullOrEmpty(username)) + { + _logger.LogDebug("Setting username"); + _username = username; + } + + if (!string.IsNullOrEmpty(password)) + { + _logger.LogDebug("Setting password"); + _password = password; + } + + _logger.LogDebug("Calling ValidateConfiguration"); + var isValid = ValidateConfiguration(); + if (!isValid) throw new ConfigurationValidationException("Invalid configuration"); + + _logger.LogDebug("Setting \"X-API-KEY\" header"); + _httpClient.DefaultRequestHeaders.Remove("X-API-KEY"); + _httpClient.DefaultRequestHeaders.Add("X-API-KEY", _apiKey); + var requestBody = new TokenRequest + { + Username = _username, + Password = _password + }; + + var requestUrl = $"{BaseUrl}/auth/v1/auth/authenticate"; + // Username (unlike ApiKey/Password) is not a secret - the plugin's own config schema marks + // it Hidden=false - and is the only field that identifies which MarkMonitor service account + // performed a given authentication. Log it so an auditor can reconstruct "who authenticated" + // from this component's own logs, including when multiple CA connector instances (each with + // a different service account) share one log sink. + _logger.LogInformation("Authenticating with MarkMonitor API at {RequestUrl} as {Username}", requestUrl, + _username); + + _logger.LogDebug("Sending authentication request"); + // Deliberately SendAndLogAsync, NOT SendWithRetryAsync: this whole method runs inside + // EnsureAuthenticatedAsync's _authLock, held by every other concurrent Enroll/Revoke/Sync + // call on this cached client that also needs a token. Retrying here would multiply the + // worst-case lock-hold time (up to 3x a full HTTP timeout, plus backoff) for everyone + // queued behind it, during exactly the "MarkMonitor is degraded" scenario where fast + // failure matters most - a caller-level retry (Command retrying the whole operation) is + // the right layer for a transient auth failure, not a retry loop inside the lock. + var response = await SendAndLogAsync(() => _httpClient.PostAsync(requestUrl, + new StringContent(JsonConvert.SerializeObject(requestBody), Encoding.UTF8, "application/json")), + "POST", requestUrl); + + _logger.LogDebug("Reading authentication response"); + var content = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) throw new Exception(BuildErrorString(content)); + + _logger.LogDebug("Deserializing token response"); + var tokenResponse = JsonConvert.DeserializeObject(content); + if (string.IsNullOrEmpty(tokenResponse?.BearerToken)) + // Covers a null/unparsable body (tokenResponse itself null) as well as a well-formed + // body simply missing/empty the "token" field (tokenResponse non-null, BearerToken + // null/empty) - either way, there is no usable bearer token to authenticate with. + throw new JsonSerializationException( + "MarkMonitor returned a successful authentication response with no usable bearer token"); + + _bearerToken = tokenResponse.BearerToken; + // Subtract a small safety buffer so a request that starts just before expiry doesn't + // race the token dying mid-flight. + _tokenExpiresAtUtc = _timeProvider.GetUtcNow().UtcDateTime.AddSeconds(tokenResponse.ExpiresIn - 30); + _logger.LogDebug("Bearer token received and valid for {TokenExpiration} seconds", + tokenResponse.ExpiresIn); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _bearerToken); + + _logger.LogInformation("Authentication successful for {Username}", _username); + } + catch (Exception e) + { + _logger.LogError("Authentication failed for {Username}: {EMessage}", _username, e.Message); + throw; + } + } + + // A page-fetch failure is already retried by SendWithRetryAsync and, if still failing, aborts the + // whole sync (completeness beats partial silence) - this threshold instead guards against the + // *per-record* processing loop below: enough individual bad records (bad status string, bad + // date, null fields) that continuing to silently skip them would produce a "successful" sync + // that quietly imported almost nothing. Not evaluated until MinimumSampleSize records have been + // observed, so a handful of bad records early in a small org's sync can't trip it. + private const int SyncErrorRateMinimumSampleSize = 50; + private const double SyncErrorRateThreshold = 0.25; + + // Each record's sync processing is an independent, order-scoped ICertificateDataReader lookup + // with no shared mutable state besides the thread-safe certificatesBuffer and the Interlocked + // counters in GetCertificateInventoryAsync - serializing them one at a time (as originally + // shipped) was pure added latency at scale with nothing to protect. Bounded, not unlimited, so a + // single page (up to PageSize=500 records) doesn't fire off hundreds of concurrent local calls. + private const int MaxConcurrentRecordsPerPage = 10; + + private enum SyncRecordOutcome + { + Emitted, + SkippedNotReady, + SkippedUnchanged + } + + public async Task GetCertificateInventoryAsync(string caId, string sort, int limit, + BlockingCollection certificatesBuffer, CancellationToken cancelToken, + ICertificateDataReader certificateDataReader = null, bool forceCompleteSync = false) + { + _logger.MethodEntry(); + var emittedCount = 0; + var skippedNotReadyCount = 0; + var skippedUnchangedCount = 0; + var erroredCount = 0; + var totalObserved = 0; + try + { + await EnsureAuthenticatedAsync(); + _logger.LogInformation("Retrieving certificate inventory from MarkMonitor"); + // Stream each page straight into certificatesBuffer as it arrives, via onPageReceived, + // instead of letting ListCertificateOrdersAsync accumulate every page for the whole order + // history into one in-memory list before this method ever touches the buffer - for a + // large/long-lived org that meant unbounded peak memory and zero buffer throughput until + // the entire (possibly huge) order history had downloaded. + await ListCertificateOrdersAsync(0, caId, sort, limit, cancelToken, async page => + { + _logger.LogDebug("Retrieved a page of '{CertificateCount}' certificate orders", page.Count); + + // Thrown outside the Parallel.ForEachAsync body below (never from inside it) so it + // always propagates as a plain Exception, not wrapped in an AggregateException - the + // only exception type a lambda body here can actually throw is OperationCanceledException + // (on real cancellation); every per-record processing failure is caught and counted + // internally instead of escaping the lambda. + Exception breakerException = null; + await Parallel.ForEachAsync(page, + new ParallelOptions { CancellationToken = cancelToken, MaxDegreeOfParallelism = MaxConcurrentRecordsPerPage }, + async (certificateDetail, ct) => + { + var observed = Interlocked.Increment(ref totalObserved); + try + { + switch (await ProcessOneInventoryRecordAsync(certificateDetail, certificatesBuffer, + certificateDataReader, forceCompleteSync, ct)) + { + case SyncRecordOutcome.Emitted: + Interlocked.Increment(ref emittedCount); + break; + case SyncRecordOutcome.SkippedNotReady: + Interlocked.Increment(ref skippedNotReadyCount); + break; + case SyncRecordOutcome.SkippedUnchanged: + Interlocked.Increment(ref skippedUnchangedCount); + break; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + // A single bad record (bad status string, bad date, null fields) must not + // abort the rest of the page/sync - certinext's model. The error-rate + // circuit breaker below is what catches this becoming systemic rather than + // a one-off. + Interlocked.Increment(ref erroredCount); + _logger.LogError( + "Error processing certificate order {CertificateId} during synchronization - skipping it and continuing: {EMessage}", + certificateDetail?.Id, e.Message); + } + + if (observed >= SyncErrorRateMinimumSampleSize) + { + var errorRate = (double)Volatile.Read(ref erroredCount) / observed; + if (errorRate > SyncErrorRateThreshold) + Interlocked.CompareExchange(ref breakerException, new Exception( + $"Aborting synchronization: {erroredCount} of {observed} records processed so far have failed ({errorRate:P0}), exceeding the {SyncErrorRateThreshold:P0} error-rate threshold"), + null); + } + }); + + // Deliberately checked after the whole page finishes rather than cancelling + // in-flight/queued iterations the instant the threshold crosses - the breaker's own + // purpose (catching systemic failure, not enforcing an exact cutoff record) tolerates + // finishing out the current page, bounded by PageSize, before aborting. + if (breakerException != null) throw breakerException; + }); + + _logger.LogInformation( + "Synchronization complete: {EmittedCount} emitted, {SkippedUnchangedCount} skipped (unchanged), {SkippedNotReadyCount} skipped (not ready), {ErroredCount} errored (of {TotalObserved} total)", + emittedCount, skippedUnchangedCount, skippedNotReadyCount, erroredCount, totalObserved); + return emittedCount; + } + catch (OperationCanceledException) + { + _logger.LogInformation("Certificate inventory retrieval cancelled"); + throw; // Rethrow the cancellation exception to ensure it's propagated + } + catch (Exception e) + { + _logger.LogError("An error has occurred: {EMessage}", e.Message); + throw; + } + finally + { + certificatesBuffer.CompleteAdding(); // Ensure buffer is completed even on cancellation + _logger.MethodExit(); + } + } + + /// + /// Processes a single sync record: adds it to and returns + /// , unless shows + /// Command already has this exact CARequestID at the exact status this record maps to - in which + /// case it's skipped () rather than re-emitted for + /// no reason. bypasses that optimization entirely. An order + /// with no cert details yet is skipped () without + /// being counted as an error - that's an expected "not ready yet" state, not a bad record. + /// + private async Task ProcessOneInventoryRecordAsync(OrderContent certificateDetail, + BlockingCollection certificatesBuffer, ICertificateDataReader certificateDataReader, + bool forceCompleteSync, CancellationToken cancelToken) + { + if (certificateDetail.Cert == null) + { + _logger.LogWarning( + "Certificate {CertificateId} has no cert details yet (status {Status}) - skipping it for this sync rather than aborting the rest of the page", + certificateDetail.Id, certificateDetail.Status); + return SyncRecordOutcome.SkippedNotReady; + } + + var certStatus = MarkMonitorCertificateStatusToCAStatus(certificateDetail); + _logger.LogTrace("Certificate {CertificateId} status: {CertificateStatus}", certificateDetail.Id, + certStatus); + + // Status alone isn't enough to prove nothing changed: MarkMonitor's own status model + // round-trips DIGI_ISSUED -> DIGI_REISSUE_PENDING -> DIGI_ISSUED for an out-of-band reissue + // of the same order ID (see Enums.cs's OrderStatus), so a sync that happens to observe the + // order only before and after that round-trip would otherwise see the same status both times + // and skip forever - silently masking a real certificate rotation from Command's inventory. + // Expiration date changes on reissue (a new cert has a new validity window), so comparing it + // too - using the same ICertificateDataReader lookup RenewOrReissue already relies on - + // catches that case without needing a "get current serial by request ID" method the + // interface doesn't expose. + if (!forceCompleteSync && certificateDataReader != null && + await certificateDataReader.DoesCertExistForRequestID(certificateDetail.Id) && + await certificateDataReader.GetStatusByRequestID(certificateDetail.Id) == certStatus && + certificateDataReader.GetExpirationDateByRequestId(certificateDetail.Id) == certificateDetail.Cert.DateValidUntil) + { + _logger.LogTrace( + "Certificate {CertificateId} is unchanged (status {CertificateStatus}) - skipping re-emission", + certificateDetail.Id, certStatus); + return SyncRecordOutcome.SkippedUnchanged; + } + + _logger.LogInformation("Adding certificate {CertificateId} to buffer", certificateDetail.Id); + _logger.LogDebug("Converting certificate {CertificateId} revocation status {Status}", + certificateDetail.Id, certificateDetail.Cert.RevokeStatus); + DateTime? revocationDate = null; + if (certificateDetail.Cert.RevokeStatus == "REVOKED") + { + _logger.LogDebug("Certificate {CertificateId} is revoked", certificateDetail.Id); + revocationDate = Convert.ToDateTime(certificateDetail.Cert.DateValidUntil); + } + + var fullChain = new StringBuilder(); + if (certificateDetail.Cert.EndEntityCert != null) + { + _logger.LogDebug("Adding end entity certificate to full chain for {CertificateId}", + certificateDetail.Id); + fullChain.AppendLine(certificateDetail.Cert.EndEntityCert); + } + if (certificateDetail.Cert.IntermediateCert != null) + { + _logger.LogDebug("Adding issuer certificate to full chain for {CertificateId}", certificateDetail.Id); + fullChain.AppendLine(certificateDetail.Cert.IntermediateCert); + } + if (certificateDetail.Cert.RootCert != null) + { + _logger.LogDebug("Adding root certificate to full chain for {CertificateId}", certificateDetail.Id); + fullChain.AppendLine(certificateDetail.Cert.RootCert); + } + + certificatesBuffer.Add( + new AnyCAPluginCertificate + { + CARequestID = certificateDetail.Id, + Status = certStatus, + Certificate = fullChain.ToString(), + CSR = certificateDetail.Cert.Csr, + ProductID = certificateDetail.CertType, + RevocationDate = revocationDate, + // RevocationReason = certificateDetail.Cert.RevokeStatus, // TODO: Not available in MarkMonitor API + }, cancelToken); + return SyncRecordOutcome.Emitted; + } + + private List BuildQueryString(int providerId, string orgId, string sort, int limit, int page) + { + _logger.MethodEntry(); + var query = new List(); + if (page > 0) query.Add($"page={page}"); + if (limit > 0) query.Add($"size={limit}"); + if (!string.IsNullOrEmpty(sort)) query.Add($"sort={Uri.EscapeDataString(sort)}"); + if (providerId > 0) query.Add($"providerId={providerId}"); + if (!string.IsNullOrEmpty(orgId)) query.Add($"organizationId={Uri.EscapeDataString(orgId)}"); + _logger.MethodExit(); + return query; + } + + private List BuildListOrgsQueryString(string name = "", string sort = "", int limit = 0, int page = 0) + { + _logger.MethodEntry(); + var query = new List(); + if (page > 0) query.Add($"page={page}"); + if (limit > 0) query.Add($"size={limit}"); + if (!string.IsNullOrEmpty(sort)) query.Add($"sort={Uri.EscapeDataString(sort)}"); + if (!string.IsNullOrEmpty(name)) query.Add($"name={Uri.EscapeDataString(name)}"); + + _logger.LogTrace("Query string: {Query}", query); + _logger.MethodExit(); + return query; + } + + /// Fetches every page of matching certificate orders. When + /// is supplied, each page is handed to it as soon as it arrives and is not also accumulated into + /// the returned list (which is then null) - lets a caller with a large result set (e.g. a full + /// inventory sync) process/forward orders page-by-page instead of holding the entire result set + /// in memory and seeing zero progress until every page has downloaded. Omit it to get the + /// original all-pages-in-one-list behavior every other caller relies on. + public async Task> ListCertificateOrdersAsync(int providerId, string orgId, string sort, + int limit, CancellationToken cancelToken = default, Func, Task> onPageReceived = null) + { + _logger.MethodEntry(); + await EnsureAuthenticatedAsync(); + var output = onPageReceived == null ? new List() : null; + try + { + _logger.LogInformation("Retrieving certificate orders from MarkMonitor"); + var currentPage = 0; + var allPagesDownloaded = false; + + do + { + cancelToken.ThrowIfCancellationRequested(); + + var nextUrl = + $"{BaseUrl}/certs/v1/order"; + _logger.LogTrace("Base URL: {BaseUrl}", nextUrl); + + _logger.LogDebug("Building query string"); + var query = BuildQueryString(providerId, orgId, sort, limit, currentPage); + if (query.Count > 0) nextUrl += "?" + string.Join("&", query); + + _logger.LogTrace("Getting page \'{CurrentPage}\' of \'{Limit}\')", currentPage, limit); + + _logger.LogDebug("Getting certificate orders from MarkMonitor {NextUrl}", nextUrl); + var response = await SendWithRetryAsync(() => _httpClient.GetAsync(nextUrl, cancelToken), "GET", + nextUrl, cancelToken); + + _logger.LogDebug("Reading response content"); + var content = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) throw new Exception(BuildErrorString(content)); + + _logger.LogDebug("Deserializing response content to MarkMonitorListOrdersResponse"); + var certificateListResponse = JsonConvert.DeserializeObject(content); + if (onPageReceived != null) + await onPageReceived(certificateListResponse.Content); + else + output.AddRange(certificateListResponse.Content); + currentPage++; + allPagesDownloaded = currentPage >= certificateListResponse.MarkMonitorPage.TotalPages; + } while (!allPagesDownloaded); + + return output; + } + catch (OperationCanceledException) + { + _logger.LogInformation("Certificate inventory retrieval cancelled"); + throw; // Rethrow the cancellation exception to ensure it's propagated + } + catch (Exception e) + { + _logger.LogError("An error has occurred: {EMessage}", e.Message); + throw; + } + finally + { + _logger.MethodExit(); + } + } + + public async Task> ListOrganizationsAsync(int page = 0, int limit = 0, + string name = "") + { + _logger.MethodEntry(); + await EnsureAuthenticatedAsync(); + var output = new List(); + try + { + _logger.LogInformation("Retrieving organizations from MarkMonitor"); + var currentPage = 0; + var allPagesDownloaded = false; + + do + { + var nextUrl = + $"{BaseUrl}/certs/v1/organization"; + + _logger.LogTrace("Base URL: {BaseUrl}", nextUrl); + + _logger.LogDebug("Building query string"); + var query = BuildListOrgsQueryString(name, "", limit, currentPage); + if (query.Count > 0) nextUrl += "?" + string.Join("&", query); + + _logger.LogTrace("Getting page \'{CurrentPage}\' of \'{Limit}\')", currentPage, limit); + + _logger.LogDebug("Getting organizations from MarkMonitor {NextUrl}", nextUrl); + var response = await SendWithRetryAsync(() => _httpClient.GetAsync(nextUrl), "GET", nextUrl); + + _logger.LogDebug("Reading response content"); + var content = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) throw new Exception(BuildErrorString(content)); + + _logger.LogDebug("Deserializing response content to MarkMonitorListOrgResponse"); + var orgListResponse = JsonConvert.DeserializeObject(content); + + _logger.LogDebug("Adding organizations to output"); + output.AddRange(orgListResponse.Content); + currentPage++; + _logger.LogTrace("Total organizations added to output: {OutputCount}", output.Count); + allPagesDownloaded = currentPage >= orgListResponse.MarkMonitorPage.TotalPages; + } while (!allPagesDownloaded); + + return output; + } + catch (OperationCanceledException) + { + _logger.LogInformation("Organization retrieval cancelled"); + throw; // Rethrow the cancellation exception to ensure it's propagated + } + catch (Exception e) + { + _logger.LogError("An error has occurred: {EMessage}", e.Message); + throw; + } + finally + { + _logger.MethodExit(); + } + } + + public async Task GetOrganizationAsync(string orgId) + { + _logger.MethodEntry(); + try + { + ValidateGuidFormat(orgId, nameof(orgId), "MarkMonitor organization ID"); + await EnsureAuthenticatedAsync(); + var url = $"{BaseUrl}/certs/v1/organization/{orgId}"; + _logger.LogDebug("Getting organization from MarkMonitor {Url}", url); + var response = await SendWithRetryAsync(() => _httpClient.GetAsync(url), "GET", url); + var content = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) throw new Exception(BuildErrorString(content)); + + return JsonConvert.DeserializeObject(content); + } + catch (Exception e) + { + // Rethrow rather than swallow to null - a transient auth/network/parsing failure here + // must not be reported to the caller identically to "this organization doesn't exist" + // (same class of gap already fixed in GetSingleOrderAsync). + _logger.LogError("An error has occurred: {EMessage}", e.Message); + throw; + } + finally + { + _logger.MethodExit(); + } + } + + public async Task> ListGroupsAsync(int page = 0, int limit = 0, string name = "") + { + _logger.MethodEntry(); + await EnsureAuthenticatedAsync(); + var output = new List(); + try + { + _logger.LogInformation("Retrieving groups from MarkMonitor"); + var currentPage = 0; + var allPagesDownloaded = false; + + do + { + var nextUrl = $"{BaseUrl}/auth/v1/group"; + + var query = BuildListOrgsQueryString(name, "", limit, currentPage); + if (query.Count > 0) nextUrl += "?" + string.Join("&", query); + + _logger.LogDebug("Getting groups from MarkMonitor {NextUrl}", nextUrl); + var response = await SendWithRetryAsync(() => _httpClient.GetAsync(nextUrl), "GET", nextUrl); + + var content = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) throw new Exception(BuildErrorString(content)); + + var groupListResponse = JsonConvert.DeserializeObject(content); + output.AddRange(groupListResponse.Groups ?? new List()); + currentPage++; + allPagesDownloaded = currentPage >= groupListResponse.MarkMonitorPage.TotalPages; + } while (!allPagesDownloaded); + + return output; + } + catch (Exception e) + { + // Group resolution is an optional, best-effort lookup (EnrollCertificateAsync falls back + // to no group on failure) - deliberately swallowed rather than failing the enrollment. + _logger.LogError("An error has occurred: {EMessage}", e.Message); + return null; + } + finally + { + _logger.MethodExit(); + } + } + + /// Fetches the raw order content for a single order ID (used both to build the + /// public-facing AnyCAPluginCertificate and, internally, to verify an order's owning + /// organization before revoking or cancelling it). is false only + /// for PollForIssuanceAsync's own per-attempt fetch - that loop is already its own outer retry + /// mechanism (a failed attempt just logs and moves to the next poll), so an inner + /// SendWithRetryAsync there would multiply a single hung/slow poll attempt by up to 3x the + /// configured timeout, blowing well past the "bounded by PickupRetries/PickupDelaySeconds" + /// latency ceiling PollForIssuanceAsync's own callers document. + private async Task FetchOrderAsync(string orderId, bool allowRetry = true) + { + ValidateGuidFormat(orderId, nameof(orderId), "MarkMonitor order ID"); + await EnsureAuthenticatedAsync(); + + // Do NOT re-set the Authorization header here: EnsureAuthenticatedAsync/AuthenticateAsync + // already set it once, under _authLock, whenever the token is (re)established - every other + // call site in this class relies on that same invariant. Setting it again here, unguarded, + // let a concurrent FetchOrderAsync call (or a concurrent re-authentication) race writes to + // the shared HttpClient's Authorization header (see GitHub issue #8). The Accept header is + // likewise set once, in the constructor - not here on every call (see its comment there). + var orderUrl = $"{BaseUrl}/certs/v1/order/{orderId}"; + var response = allowRetry + ? await SendWithRetryAsync(() => _httpClient.GetAsync(orderUrl), "GET", orderUrl) + : await SendAndLogAsync(() => _httpClient.GetAsync(orderUrl), "GET", orderUrl); + var content = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) throw new Exception(BuildErrorString(content)); + return JsonConvert.DeserializeObject(content); + } + + /// + /// MarkMonitor always returns a freshly-created order in a pending state (typically + /// EXTERNALVALIDATION) - unlike some other CA APIs, it never issues synchronously from the + /// create-order call. For a product that resolves DCV/approval quickly, polling here for up to + /// attempts (every + /// seconds) lets Enroll return the issued + /// certificate directly instead of always making Command wait for the next sync. Falls back to + /// returning unchanged - today's existing behavior - if + /// PickupRetries is 0, the order already reached a terminal state, or the budget is + /// exhausted before issuance. Each poll's own fetch is deliberately single-attempt (see + /// 's allowRetry parameter) so this loop's real worst-case + /// wall-clock time stays close to PickupRetries * (PickupDelaySeconds + TimeoutSeconds) + /// rather than being multiplied further by an inner retry cascade on top of this outer one. + /// + private async Task PollForIssuanceAsync(OrderContent order, MarkMonitorConfig config) + { + if (config.PickupRetries <= 0 || IsPollingComplete(order)) return order; + + var delay = TimeSpan.FromSeconds(config.PickupDelaySeconds); + for (var attempt = 1; attempt <= config.PickupRetries; attempt++) + { + await _delay(delay, CancellationToken.None); + + OrderContent polled; + try + { + polled = await FetchOrderAsync(order.Id, allowRetry: false); + } + catch (Exception e) + { + // A transient failure to poll must not fail an enrollment whose order was already + // created successfully - just retry on the next attempt with the last known state. + _logger.LogWarning( + "Pickup poll {Attempt}/{MaxAttempts} for order {CARequestID} failed - will retry: {EMessage}", + attempt, config.PickupRetries, order.Id, e.Message); + continue; + } + + order = polled; + if (IsPollingComplete(order)) break; + } + + _logger.LogInformation( + "Pickup polling for order {CARequestID} finished after up to {MaxAttempts} attempt(s), current status {OrderStatus}", + order.Id, config.PickupRetries, order.Status); + return order; + } + + // True once the order has either issued (with a cert body actually present) or reached a + // terminal state that will never produce one - no point spending the rest of the poll budget + // either way. + private bool IsPollingComplete(OrderContent order) + { + var status = MarkMonitorCertificateStatusToCAStatus(order); + return (status == (int)EndEntityStatus.GENERATED && order.Cert?.EndEntityCert != null) || + status == (int)EndEntityStatus.FAILED || status == (int)EndEntityStatus.CANCELLED || + status == (int)EndEntityStatus.REVOKED; + } + + /// Resolves a CA connection's configured OrgId (which may be a friendly name or a + /// GUID) to the organization's real GUID. + private async Task ResolveOrganizationIdAsync(string orgNameOrId) + { + if (Guid.TryParse(orgNameOrId, out _)) return orgNameOrId; + if (_resolvedOrgIdByName.TryGetValue(orgNameOrId, out var cachedOrgId)) return cachedOrgId; + + var orgs = await ListOrganizationsAsync(0, NameResolutionPageSize, orgNameOrId); + // MarkMonitor's own name filter may do substring/fuzzy matching rather than exact matching, + // so filter to an exact (case-insensitive) name match ourselves rather than trusting the + // first result - otherwise a configured name that's a substring of another org's name (e.g. + // "Acme" vs "Acme Corp Europe") could silently resolve to the wrong organization, which would + // undermine the cross-org ownership check in RevokeCertificateAsync. + var resolvedOrgId = + orgs.FirstOrDefault(o => string.Equals(o.Name, orgNameOrId, StringComparison.OrdinalIgnoreCase))?.Id; + if (resolvedOrgId != null) _resolvedOrgIdByName[orgNameOrId] = resolvedOrgId; + return resolvedOrgId; + } + + /// + /// Verifies that belongs to the configured organization before + /// allowing a destructive action (revoke/cancel) against it. Shared by + /// and so the two stay + /// behaviorally identical on this check. + /// + /// MarkMonitor's own ignoreOrgCheck=false default only guards against acting on an order that + /// belongs to a different reseller account entirely - it has no notion of the specific + /// sub-organization this CA connector is scoped to. That distinction matters most for + /// RenewOrReissue, where the order ID being revoked comes from Command's ICertificateDataReader + /// rather than from this org's own enrollment - so verify it here rather than trusting the + /// caller (or MarkMonitor) to have scoped it correctly. + /// + /// A blank orgName intentionally skips this check (ad-hoc/manual callers that don't scope by + /// organization), but that must never happen silently - it's logged explicitly so a production + /// caller unexpectedly hitting this path (e.g. a misconfigured OrgId) is visible in the logs + /// rather than looking identical to a passing check. + /// + /// The order being acted on. + /// The configured organization name or GUID; blank skips the check. + /// Present-participle form of the action for log text (e.g. "Revoking", "Cancelling"). + /// Infinitive form of the action for log/exception text (e.g. "revoke", "cancel"). + private async Task EnsureOrderBelongsToOrganizationAsync(string orderId, string orgName, string actionGerund, + string actionVerb) + { + if (string.IsNullOrWhiteSpace(orgName)) + { + _logger.LogWarning( + "{ActionGerund} order {OrderId} with no organization to verify ownership against - the cross-organization ownership check was skipped", + actionGerund, orderId); + return; + } + + // Independent lookups (one depends only on orgName, the other only on orderId) - run them + // concurrently rather than one after another. Both internally call EnsureAuthenticatedAsync, + // which is safe under concurrent access (see its own comment on _authLock). + var expectedOrgIdTask = ResolveOrganizationIdAsync(orgName); + var orderTask = FetchOrderAsync(orderId); + await Task.WhenAll(expectedOrgIdTask, orderTask); + var expectedOrgId = expectedOrgIdTask.Result; + var order = orderTask.Result; + // Comparing as parsed Guids, not raw strings: Guid.TryParse accepts several textual + // formats (braces, no dashes, etc.), so an admin-configured OrgId in a non-canonical + // format must still match MarkMonitor's own canonical serialization of the same GUID. + var actualOrgIdParsed = Guid.TryParse(order.OrganizationId, out var actualOrgId) ? actualOrgId : (Guid?)null; + var expectedOrgIdParsed = expectedOrgId != null && Guid.TryParse(expectedOrgId, out var parsedExpected) + ? parsedExpected + : (Guid?)null; + if (expectedOrgIdParsed == null || actualOrgIdParsed == null || actualOrgIdParsed != expectedOrgIdParsed) + { + _logger.LogError( + "Refusing to {ActionVerb} order {OrderId}: it belongs to organization {ActualOrgId}, but the configured organization {ConfiguredOrgName} resolved to {ExpectedOrgId}", + actionVerb, orderId, order.OrganizationId, orgName, expectedOrgId); + throw new Exception($"Order {orderId} belongs to a different organization than the configured '{orgName}' - refusing to {actionVerb} it"); + } + } + + public async Task GetSingleOrderAsync(string orderId) + { + try + { + var order = await FetchOrderAsync(orderId); + + // order.Cert can be null for an order that hasn't progressed far enough yet (e.g. + // CREATED/DIGI_NEEDS_CSR) - same class of gap already fixed in GetCertificateInventoryAsync. + DateTime? revocationDate = null; + if (order.Cert?.RevokeStatus == "REVOKED") revocationDate = Convert.ToDateTime(order.Cert.DateValidUntil); + + return new AnyCAPluginCertificate + { + CARequestID = order.Id, + Certificate = order.Cert?.EndEntityCert, + Status = MarkMonitorCertificateStatusToCAStatus(order), + ProductID = order.CertType, + RevocationDate = revocationDate + }; + } + catch (Exception e) + { + // Rethrow rather than swallow to null - GetSingleRecord (the connector method that + // calls this) needs the real exception to log a failure instead of silently reporting + // "not found" for what was actually an auth/network/parsing error. + _logger.LogError("An error has occurred: {EMessage}", e.Message); + throw; + } + } + + /// + /// Order IDs are interpolated directly into request URLs - a corrupted or manipulated + /// CARequestID should fail fast with a clear error rather than silently producing an unexpected + /// path segment. + /// + private static void ValidateGuidFormat(string value, string paramName, string description) + { + if (!Guid.TryParse(value, out _)) + // The rejected value is embedded in this exception's own Message, which multiple callers + // (this class's own catch blocks, and MarkMonitorCAConnector's) log verbatim via e.Message + // - sanitize it here, at the source, rather than trying to catch every downstream log call + // that might surface it (CWE-117). + throw new ArgumentException( + $"'{LogSanitizer.ForLog(value)}' is not a valid {description} (expected a GUID)", paramName); + } + + private string getCsrAlgorithm(Pkcs10CertificationRequest csr) + { + var signatureAlgorithm = csr.SignatureAlgorithm.Algorithm.Id; + var requestAlgorithm = signatureAlgorithm switch + { + //check if algorithm is RSA or ECC + "1.2.840.113549.1.1.11" => AlgorithmTypes.Rsa.GetDescription(), + "1.2.840.10045.4.3.1" or "1.2.840.10045.4.3.2" or "1.2.840.10045.4.3.3" or "1.2.840.10045.4.3.4" + or "1.2.840.10045.2.1" => AlgorithmTypes.Ecc.GetDescription(), + // "2.16.840.1.101.3.4.3.1" or "2.16.840.1.101.3.4.3.2" or "2.16.840.1.101.3.4.3.3" + // or "2.16.840.1.101.3.4.3.4" => AlgorithmTypes.Dsa.GetDescription(), //DSA not supported + _ => throw new Exception($"Invalid CSR signature algorithm {signatureAlgorithm}") + }; + return requestAlgorithm; + } + + private const string EcPublicKeyOid = "1.2.840.10045.2.1"; + + /// + /// MarkMonitor's DigiCert-backed products silently reject an ECC CSR whose public key uses + /// explicit curve parameters (the curve's prime/coefficients/base point spelled out) instead of + /// a named-curve OID reference - the order fails almost instantly with no reason surfaced + /// anywhere in MarkMonitor's API (confirmed by decoding a real rejected order's CSR). CA/Browser + /// Forum baseline requirements disallow explicit parameters for publicly-trusted certs, so this + /// fails fast with an actionable message here rather than silently forwarding an order that + /// MarkMonitor will just as silently fail. + /// + private static void ValidateEccCsrUsesNamedCurve(SubjectPublicKeyInfo publicKeyInfo) + { + if (publicKeyInfo.Algorithm.Algorithm.Id != EcPublicKeyOid) return; + + var ecParameters = X962Parameters.GetInstance(publicKeyInfo.Algorithm.Parameters); + if (!ecParameters.IsNamedCurve) + throw new ArgumentException( + "ECC CSR uses explicit curve parameters instead of a named curve (e.g. P-256/secp256r1) - MarkMonitor requires a named curve and will silently fail the order otherwise"); + } + + // A reservation is "still active" - and must keep being awaited rather than replaced - if either + // its nominal window hasn't elapsed yet, or its own call simply hasn't finished yet. The latter + // matters when a call's real work (org/group lookups, CSR parsing, the order-create HTTP call + // itself) takes longer than RecentEnrollmentWindow: without it, a retry arriving after the nominal + // window - but while the original call is still genuinely in flight - would win a fresh + // reservation and create a real second order, exactly the outcome this cache exists to prevent. + private static bool IsReservationStillActive( + (DateTime ExpiresAtUtc, TaskCompletionSource Tcs) entry, DateTime now) => + entry.ExpiresAtUtc > now || !entry.Tcs.Task.IsCompleted; + + // _recentEnrollments has no eviction path for a *successful* enrollment - the dedupe key is + // built from the CSR, which is unique per real-world request, so a completed success entry is + // essentially never looked up again and would otherwise sit in the dictionary (holding the full + // CSR and issued cert chain) for the remaining lifetime of the process. Sweeping expired-and- + // completed entries here, on every enrollment call, keeps the dictionary bounded by "enrollments + // within the last RecentEnrollmentWindow" instead of "enrollments ever performed" - without a + // separate timer/thread to manage. Only entries IsReservationStillActive already says are safe to + // drop (window elapsed AND the call finished) are removed, so a genuinely in-flight reservation is + // never touched. + private void PruneExpiredReservations(DateTime now) + { + foreach (var entry in _recentEnrollments) + if (!IsReservationStillActive(entry.Value, now)) + TryRemoveReservation(entry.Key, entry.Value); + } + + // ConcurrentDictionary's own Remove(key) doesn't check the value, so it can delete a different + // caller's reservation that has since replaced the one this call actually owned for the same + // key - going through ICollection> gives an atomic, conditional remove-if-still- + // equal-to-this-value instead. + private bool TryRemoveReservation(string key, (DateTime ExpiresAtUtc, TaskCompletionSource Tcs) entry) => + ((ICollection)>>)_recentEnrollments) + .Remove(new KeyValuePair)>(key, entry)); + + public async Task EnrollCertificateAsync(string csr, string subject, + Dictionary san, string orderType, Dictionary productParams, + MarkMonitorConfig config) + { + _logger.MethodEntry(); + var dedupeKey = $"{config.OrgName}|{orderType}|{subject}|{csr}"; + // Subject is fully requester-controlled (straight off the submitted CSR) - log a CR/LF- + // escaped copy everywhere below so an embedded CR/LF can't forge a fake log line (CWE-117). + var logSafeSubject = LogSanitizer.ForLog(subject); + TaskCompletionSource ownedReservation = null; + (DateTime ExpiresAtUtc, TaskCompletionSource Tcs) ownedEntry = default; + // Only a failure at or after the actual order-create call can mean "MarkMonitor might have + // created the order before we found out" - a network blip during an earlier step (org/contact/ + // group resolution, CSR parsing) never reached that endpoint at all, so it can never have + // created an order and must not lock out a same-second retry for the rest of the window. + var reachedCreateOrderCall = false; + try + { + await EnsureAuthenticatedAsync(); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + PruneExpiredReservations(now); + if (_recentEnrollments.TryGetValue(dedupeKey, out var existing) && IsReservationStillActive(existing, now)) + { + var dedupedResult = await existing.Tcs.Task; + _logger.LogWarning( + "An identical enrollment for subject {Subject} was already submitted in the last {Minutes} minute(s) - folded into existing order {CARequestID} instead of creating a duplicate", + logSafeSubject, RecentEnrollmentWindow.TotalMinutes, dedupedResult.CARequestID); + return dedupedResult; + } + + // Reserve this key *before* doing any real work, so a retry that arrives while this + // call is still in flight (the scenario this cache actually exists to prevent) finds + // the reservation and awaits it, rather than racing to create a second order. + var candidateTcs = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + // AddOrUpdate, not GetOrAdd: an expired-and-completed entry must be replaced, not returned + // as-is - GetOrAdd would hand back the stale (already-completed) reservation forever once + // one exists for this key. A reservation is only replaced once BOTH its nominal window has + // elapsed AND its own call has actually finished - a still-running call that happens to run + // longer than the window must keep being awaited, not raced by a second real order. + var reserved = _recentEnrollments.AddOrUpdate( + dedupeKey, + (now + RecentEnrollmentWindow, candidateTcs), + (_, current) => IsReservationStillActive(current, now) + ? current + : (now + RecentEnrollmentWindow, candidateTcs)); + if (reserved.Tcs != candidateTcs) + { + _logger.LogWarning( + "An identical enrollment for subject {Subject} is already in flight - awaiting that result instead of creating a duplicate order", + logSafeSubject); + return await reserved.Tcs.Task; + } + + ownedReservation = candidateTcs; + ownedEntry = reserved; + + var caseInsensitiveParams = new Dictionary(productParams, StringComparer.OrdinalIgnoreCase); + + var additionalEmails = + caseInsensitiveParams.GetValueOrDefault("additionalEmails"); + var additionalEmailsList = new List(); + if (!string.IsNullOrEmpty(additionalEmails)) + { + additionalEmails = additionalEmails.Replace(" ", ","); + additionalEmailsList = additionalEmails + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + } + + var org = await ResolveOrganizationAsync(config.OrgName); + var orgIdGuid = Guid.Parse(org.Id); + + var comments = caseInsensitiveParams.GetValueOrDefault( + MarkMonitorCAPluginConfig.EnrollmentConfigConstants.Comments, "Requested via Keyfactor Command"); + _logger.LogTrace("Comments: {Comments}", LogSanitizer.ForLog(comments)); + var locale = caseInsensitiveParams.GetValueOrDefault( + MarkMonitorCAPluginConfig.EnrollmentConfigConstants.Locale, "en"); + _logger.LogTrace("Locale: {Locale}", LogSanitizer.ForLog(locale)); + var provider = caseInsensitiveParams.GetValueOrDefault( + MarkMonitorCAPluginConfig.EnrollmentConfigConstants.Provider, "DIGICERT"); + _logger.LogTrace("Provider: {Provider}", LogSanitizer.ForLog(provider)); + + _logger.LogDebug("Resolving MarkMonitor contact for order"); + var contactParam = caseInsensitiveParams.GetValueOrDefault( + MarkMonitorCAPluginConfig.EnrollmentConfigConstants.MarkmonitorContact); + var resolvedContact = ResolveContact(org?.Contacts, contactParam); + var orderContacts = resolvedContact != null + ? new List + { + new() { Id = resolvedContact.Id, ContactTypes = resolvedContact.ContactTypes } + } + : new List(); + if (resolvedContact == null) + _logger.LogWarning( + "No MarkMonitor contact could be resolved for organization {OrgName}; MarkMonitor may reject the order if a contact is required", + config.OrgName); + else + _logger.LogTrace("Resolved MarkMonitor contact: {ContactId} ({Email})", resolvedContact.Id, + resolvedContact.Email); + + _logger.LogDebug("Resolving MarkMonitor group for order"); + var groupParam = caseInsensitiveParams.GetValueOrDefault( + MarkMonitorCAPluginConfig.EnrollmentConfigConstants.MarkmonitorGroup); + var groupIdGuid = await ResolveGroupIdAsync(groupParam); + + var dcvMethod = ValidateDcvMethod(caseInsensitiveParams.GetValueOrDefault( + MarkMonitorCAPluginConfig.EnrollmentConfigConstants.DCVMethod, + DomainControlValidationMethods.Email.GetDescription())); + + // Lookup order type in CertOrderTypes enum. Enum.Parse alone would silently "succeed" for + // any numeric string that fits the underlying int type even with no member actually + // defined for that value (e.g. "20" for a 12-member enum) - Enum.IsDefined enforces + // membership, matching the same check ValidateProductInfo already applies at template-save + // time (this is the same gap, at the point it would otherwise be discovered). + _logger.LogDebug("Looking up order type {OrderType} in CertOrderTypes enum", orderType); + var certOrderType = Enum.Parse(orderType); + if (!Enum.IsDefined(typeof(CertOrderTypes), certOrderType)) + throw new ArgumentException( + $"'{LogSanitizer.ForLog(orderType)}' is not a valid MarkMonitor product ID"); + + _logger.LogDebug("Deserializing CSR"); + var csrObject = new Pkcs10CertificationRequest(GetCsrBytes(csr)); + var csrInfo = csrObject.GetCertificationRequestInfo(); + ValidateEccCsrUsesNamedCurve(csrInfo.SubjectPublicKeyInfo); + + _logger.LogDebug("Determining CSR algorithm"); + var requestAlgorithm = getCsrAlgorithm(csrObject); + _logger.LogTrace("CSR algorithm: {RequestAlgorithm}", requestAlgorithm); + + _logger.LogDebug("Converting CSR to PEM"); + var csrPem = PemUtilities.DERToPEM(csrObject.GetEncoded(), PemUtilities.PemObjectType.CertRequest); + + var commonName = cleanSubject(subject); + var dnsNames = BuildDnsNames(csrObject, commonName, san); + + _logger.LogDebug("Constructing certificate order object"); + var certOrder = new MarkMonitorCreateOrderRequest + { + AdditionalEmails = additionalEmailsList, + SkipPrice = true, + OrganizationId = orgIdGuid, + GroupId = groupIdGuid, + Contacts = orderContacts, + Comments = comments, + CertType = certOrderType.GetDescription(), + Locale = locale, + Provider = provider, + Cert = new MarkMonitorOrderRequestCert + { + CommonName = commonName, + DnsNames = dnsNames, + Csr = csrPem.Replace("\r", ""), + DcvMethod = dcvMethod, + // dcvEmails is not marked required by MarkMonitor's schema, and leaving it empty + // has been verified against the live API to succeed for DCVMethod=EMAIL - + // MarkMonitor falls back to the domain/org's registered DCV contacts. Revisit if + // that ever changes; there's no documented case where an explicit approver email + // is actually required here. + DcvEmails = new List(), + AlgorithmHash = requestAlgorithm + } + }; + + + _logger.LogDebug("Calling CreateCertificateOrder"); + // Force any needed re-authentication here, BEFORE reachedCreateOrderCall is set. + // CreateCertificateOrder performs its own EnsureAuthenticatedAsync check internally (every + // MarkMonitorClient method does), and if the token happened to expire again during the + // org/contact/group resolution above, a failure in THAT nested re-auth call must not be + // treated as ambiguous - the order-create POST itself was never reached - but + // reachedCreateOrderCall wouldn't otherwise distinguish that from a failure during the + // POST. Doing the check here first means CreateCertificateOrder's own check is always a + // no-op immediately afterward. + await EnsureAuthenticatedAsync(); + reachedCreateOrderCall = true; + var order = await CreateCertificateOrder(certOrder); + + if (order == null) throw new Exception($"Failed to enroll certificate `{logSafeSubject}` with MarkMonitor"); + + _logger.LogInformation("Certificate enrolled successfully"); + _logger.LogInformation( + "Order {CARequestID} was created with ContactId {ContactId} and GroupId {GroupId}", order.Id, + resolvedContact?.Id, groupIdGuid); + + // Polling happens here, before the reservation resolves, so a concurrent duplicate call + // that's folded into this reservation (the dedup-hit path above) gets the polled result + // too, rather than always seeing the original pending status. + order = await PollForIssuanceAsync(order, config); + + var certificate = order.Cert?.EndEntityCert; + var status = MarkMonitorCertificateStatusToCAStatus(order); + if (status == (int)EndEntityStatus.GENERATED && certificate == null) + { + // MarkMonitor's status can flip to issued a moment before the cert body itself is + // populated - PollForIssuanceAsync's own IsPollingComplete check already accounts for + // this (it requires both), but if the poll budget is exhausted at exactly that + // moment, the order comes back here with an "issued" status and no cert. Reporting + // GENERATED with a null Certificate would be an internally inconsistent result no + // caller expects for a successful enrollment - INPROCESS (the same status used for + // every other pending state) is the honest signal that there's nothing to deliver yet. + _logger.LogWarning( + "Order {CARequestID} reports status {OrderStatus} (maps to GENERATED) but its certificate body is not yet populated - reporting INPROCESS instead of a false GENERATED", + order.Id, order.Status); + status = (int)EndEntityStatus.INPROCESS; + } + + var enrollmentResult = new EnrollmentResult + { + CARequestID = order.Id, + Certificate = certificate, + Status = status, + StatusMessage = "MarkMonitor order status: " + order.Status + }; + ownedReservation.SetResult(enrollmentResult); + return enrollmentResult; + } + catch (Exception e) + { + _logger.LogError("An error has occurred: {EMessage}", e.Message); + if (ownedReservation != null) + { + ownedReservation.SetException(e); + + // A transport-level failure (timeout, dropped connection) DURING the order-create call + // itself means we genuinely don't know whether MarkMonitor actually created the order + // before the failure - exactly the ambiguous case this cache exists to guard against + // (see the class-level comment). Don't evict the reservation for that case: keep it in + // _recentEnrollments so a Command retry within the window is folded into this (now- + // faulted) reservation instead of racing ahead to create a second real order. The same + // exception types raised by an EARLIER step (org/contact/group resolution, CSR parsing) + // are NOT ambiguous - reachedCreateOrderCall gates on that, since those steps never + // reach MarkMonitor's create-order endpoint and so can never have created an order; a + // retry after one of those must get a fresh attempt immediately, not be locked out for + // the rest of the window by an unrelated transient blip. Conditional Remove, not a bare + // key-based one: if this reservation's own window already expired while this call was + // still running, a different caller may have since won a fresh reservation for the same + // key - an unconditional remove would delete THEIR entry instead of (the no-longer- + // present) one this call owned. + // MarkMonitorOrderCreatedButUnparsableException means MarkMonitor already confirmed + // (2xx) the order was created - stronger than merely ambiguous - so it must never be + // treated as safe to evict either. + var isAmbiguousOutcome = reachedCreateOrderCall && + e is HttpRequestException or TaskCanceledException or MarkMonitorOrderCreatedButUnparsableException; + if (isAmbiguousOutcome) + _logger.LogWarning( + "Enrollment for subject {Subject} failed with an ambiguous network-level error - keeping the retry-dedup reservation active for the rest of the window so a retry doesn't risk creating a duplicate MarkMonitor order", + logSafeSubject); + else + TryRemoveReservation(dedupeKey, ownedEntry); + } + + throw; + } + finally + { + _logger.MethodExit(); + } + } + + private async Task ResolveOrganizationAsync(string orgNameOrId) + { + MarkMonitorOrganizationResponse org; + if (Guid.TryParse(orgNameOrId, out _)) + { + _logger.LogDebug("OrgId '{OrgName}' looks like a GUID - fetching the organization directly", + orgNameOrId); + org = await GetOrganizationAsync(orgNameOrId); + } + else + { + var orgs = await ListOrganizationsAsync(0, NameResolutionPageSize, orgNameOrId); + _logger.LogTrace("Organizations found: {@Orgs}", orgs); + // ListOrganizationsAsync throws rather than returning null on error, so orgs is never + // null here - just possibly empty. MarkMonitor's own name filter may do substring/fuzzy + // matching rather than exact matching, so filter to an exact (case-insensitive) name + // match ourselves rather than trusting the first result - otherwise a configured name + // that's a substring of another org's name (e.g. "Acme" vs "Acme Corp Europe") could + // silently resolve to the wrong organization. + org = orgs.FirstOrDefault(o => string.Equals(o.Name, orgNameOrId, StringComparison.OrdinalIgnoreCase)); + } + + _logger.LogTrace("Organization ID: {OrgId}", org?.Id); + if (string.IsNullOrEmpty(org?.Id)) + { + _logger.LogError("Organization ID not found for {OrgName}", orgNameOrId); + throw new InvalidDataException($"Organization ID '{orgNameOrId}' not found"); + } + + return org; + } + + // Unlike contacts (scoped to an organization's own Contacts list), group resolution can't be + // scoped to the configured organization: MarkMonitor's Auth API models groups as + // account/tenant-wide - /auth/v1/group has no organizationId filter, and the Group schema it + // returns (id/name/description/dateCreated/dateUpdated) has no organizationId field to check + // against either. There is nothing in this API to scope against, so a group name/GUID that + // resolves at all is accepted as-is; matching is by exact (case-insensitive) name rather than a + // substring, which is the closest available mitigation. + private async Task ResolveGroupIdAsync(string groupParam) + { + if (string.IsNullOrWhiteSpace(groupParam)) return null; + + if (Guid.TryParse(groupParam, out var parsedGroupId)) return parsedGroupId; + + if (_resolvedGroupIdByName.TryGetValue(groupParam, out var cachedGroupId)) return cachedGroupId; + + var groups = await ListGroupsAsync(0, NameResolutionPageSize, groupParam); + var matchedGroup = groups?.FirstOrDefault(g => + string.Equals(g.Name, groupParam, StringComparison.OrdinalIgnoreCase)); + if (matchedGroup != null) + { + var resolvedGroupId = Guid.Parse(matchedGroup.Id); + _resolvedGroupIdByName[groupParam] = resolvedGroupId; + return resolvedGroupId; + } + + _logger.LogWarning("MarkMonitor group '{GroupParam}' could not be resolved to an ID", + LogSanitizer.ForLog(groupParam)); + return null; + } + + private string ValidateDcvMethod(string dcvMethod) + { + var validDcvMethods = Enum.GetValues() + .Select(m => m.GetDescription()).ToList(); + if (validDcvMethods.Contains(dcvMethod, StringComparer.OrdinalIgnoreCase)) return dcvMethod; + + _logger.LogWarning("Invalid DCVMethod '{DcvMethod}' specified, defaulting to EMAIL", + LogSanitizer.ForLog(dcvMethod)); + return DomainControlValidationMethods.Email.GetDescription(); + } + + private MarkMonitorGetContactResponse ResolveContact(List contacts, + string contactParam) + { + if (contacts == null || contacts.Count == 0) return null; + + if (!string.IsNullOrWhiteSpace(contactParam)) + { + if (Guid.TryParse(contactParam, out var contactGuid)) + { + var byId = contacts.FirstOrDefault(c => c.Id == contactGuid); + if (byId != null) return byId; + } + + var byEmail = + contacts.FirstOrDefault(c => string.Equals(c.Email, contactParam, StringComparison.OrdinalIgnoreCase)); + if (byEmail != null) return byEmail; + + var byName = contacts.FirstOrDefault(c => + string.Equals($"{c.FirstName} {c.LastName}", contactParam, StringComparison.OrdinalIgnoreCase)); + if (byName != null) return byName; + + _logger.LogWarning( + "MarkMonitor contact '{ContactParam}' could not be resolved; falling back to the default organization contact", + LogSanitizer.ForLog(contactParam)); + } + + return contacts.FirstOrDefault(c => + c.ContactTypes != null && c.ContactTypes.Any(t => + string.Equals(t.Type, "ORGANIZATION_CONTACT", StringComparison.OrdinalIgnoreCase))) + ?? contacts.FirstOrDefault(); + } + + /// + /// MarkMonitor's API does not use the submitted CSR to determine a certificate's issued SAN + /// list - real captured orders (and DigiCert's own docs, MarkMonitor's sole provider) show the + /// issued SANs are the union of the order's commonName and its own dnsNames field. + /// A CSR's SAN extension is ignored server-side unless those same names are also placed in + /// dnsNames here - so the Command-supplied dictionary is the + /// primary source. A SAN extension embedded in itself is used only + /// as a fallback when is null - Command never populated SAN data + /// at all for this request - and never when it's non-null, even empty: a non-null dictionary + /// means Command's own enrollment pattern/template ran and is the authoritative source for this + /// request, and a raw CSR-embedded SAN extension (subscriber-generated, outside Command's own + /// policy/RA control) must not override or supplement it. certinext-caplugin's own history has + /// the identical unconditional-union pattern deliberately reverted for this exact reason - a + /// subscriber's own CSR can carry more names than an enrollment pattern actually authorized. + /// Non-DNS SAN types (IP/email/URI) have no field in MarkMonitor's order schema and are dropped + /// with a logged warning rather than failing the enrollment. + /// + private List BuildDnsNames(Pkcs10CertificationRequest csrObject, string commonName, + Dictionary san) + { + var dnsNames = new List(); + + if (san != null) + { + var droppedTypes = new List(); + foreach (var entry in san) + { + if (entry.Value == null) continue; + + // Command's SAN type keys (e.g. "Dns"/"dnsname") vary in casing across gateways - + // matched case-insensitively, mirroring digicert-certcentral-caplugin's own + // defensive handling of the same ambiguity. + if (string.Equals(entry.Key, "dns", StringComparison.OrdinalIgnoreCase) || + string.Equals(entry.Key, "dnsname", StringComparison.OrdinalIgnoreCase)) + dnsNames.AddRange(entry.Value.Where(v => !string.IsNullOrWhiteSpace(v))); + else if (entry.Value.Length > 0) + droppedTypes.Add(entry.Key); + } + + if (droppedTypes.Count > 0) + _logger.LogWarning( + "MarkMonitor's order schema has no field for non-DNS SAN type(s) {DroppedSanTypes} - they were requested but will not be included on this order", + LogSanitizer.ForLog(string.Join(", ", droppedTypes))); + } + else + { + var requestedExtensions = csrObject.GetRequestedExtensions(); + var sanExtension = requestedExtensions?.GetExtension(X509Extensions.SubjectAlternativeName); + if (sanExtension != null) + { + var generalNames = GeneralNames.GetInstance(sanExtension.GetParsedValue()); + dnsNames.AddRange(generalNames.GetNames() + .Where(name => name.TagNo == GeneralName.DnsName) + .Select(name => name.Name.ToString())); + } + } + + // The CN is submitted separately as Cert.CommonName, and MarkMonitor issues CN ∪ dnsNames - + // repeating it in dnsNames would be redundant, so it's excluded here to keep the request + // minimal. + var distinctDnsNames = dnsNames + .Where(name => !string.IsNullOrWhiteSpace(name) && + !string.Equals(name, commonName, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return distinctDnsNames.Count > 0 ? distinctDnsNames : null; + } + + private string cleanSubject(string subject) + { + _logger.MethodEntry(); + try + { + if (string.IsNullOrWhiteSpace(subject)) return subject; + + try + { + var cnValues = new X509Name(subject).GetValueList(X509Name.CN); + if (cnValues.Count > 0) return (string)cnValues[cnValues.Count - 1]; + } + catch (Exception e) + { + _logger.LogDebug( + "Could not parse subject '{Subject}' as an X509 DN, falling back to string search: {EMessage}", + LogSanitizer.ForLog(subject), e.Message); + } + + // Fallback for a subject that isn't a fully valid DN (e.g. bare "CN=foo" with no other RDNs). + var cnPrefix = "CN="; + var cnIndex = subject.IndexOf(cnPrefix, StringComparison.OrdinalIgnoreCase); + if (cnIndex < 0) return subject; + var cnStart = cnIndex + cnPrefix.Length; + var cnEnd = subject.IndexOf(",", cnStart, StringComparison.Ordinal); + if (cnEnd < 0) cnEnd = subject.Length; + return subject.Substring(cnStart, cnEnd - cnStart); + } + finally + { + _logger.MethodExit(); + } + } + + private void logCreateOrderRequest(MarkMonitorCreateOrderRequest request) + { + _logger.MethodEntry(); + _logger.LogTrace("CommonName: {CommonName}", LogSanitizer.ForLog(request.Cert.CommonName)); + _logger.LogTrace("DnsNames: {DnsNames}", + LogSanitizer.ForLog(request.Cert.DnsNames != null ? string.Join(",", request.Cert.DnsNames) : null)); + _logger.LogTrace("OrganizationId: {OrganizationId}", request.OrganizationId); + _logger.LogTrace("GroupId: {GroupId}", request.GroupId); + _logger.LogTrace("CertType: {CertType}", request.CertType); + _logger.LogTrace("Locale: {Locale}", LogSanitizer.ForLog(request.Locale)); + _logger.LogTrace("Provider: {Provider}", LogSanitizer.ForLog(request.Provider)); + _logger.LogTrace("Comments: {Comments}", LogSanitizer.ForLog(request.Comments)); + // Deliberately not logging AdditionalEmails (requester PII) or the CSR/full Cert object - + // just enough to confirm the shape of the request without leaking their content. + _logger.LogTrace("AdditionalEmails count: {AdditionalEmailsCount}", request.AdditionalEmails?.Count ?? 0); + _logger.LogTrace("SkipPrice: {SkipPrice}", request.SkipPrice); + _logger.LogTrace("DcvMethod: {DcvMethod}", LogSanitizer.ForLog(request.Cert.DcvMethod)); + _logger.MethodExit(); + } + + public async Task CreateCertificateOrder(MarkMonitorCreateOrderRequest request) + { + _logger.MethodEntry(); + try + { + await EnsureAuthenticatedAsync(); + logCreateOrderRequest(request); + + var url = $"{BaseUrl}/certs/v1/order"; + _logger.LogDebug("Creating certificate order at {Url}", url); + var jsonPayload = new StringContent( + JsonConvert.SerializeObject(request), + Encoding.UTF8, "application/json" + ); + // Deliberately SendAndLogAsync, NOT SendWithRetryAsync: a transport-level failure here + // means MarkMonitor may have already created the order before we found out - retrying + // this specific call would risk creating a second, real, billable order. That ambiguity + // is instead handled one level up, by EnrollCertificateAsync's dedup reservation staying + // active so a caller-level retry folds into this same attempt's result. + var response = await SendAndLogAsync(() => _httpClient.PostAsync(url, jsonPayload), "POST", url); + _logger.LogTrace("Response: {Response}", response); + + _logger.LogDebug("Reading response content"); + var content = await response.Content.ReadAsStringAsync(); + _logger.LogTrace("Response content: {Content}", content); + if (response.IsSuccessStatusCode) + { + OrderContent order; + try + { + order = JsonConvert.DeserializeObject(content); + } + catch (Exception parseException) + { + // MarkMonitor already returned a success status here - the order was definitely + // created, even though its response body didn't parse - so this must be treated + // as at least as ambiguous as a network-level failure (EnrollCertificateAsync's + // isAmbiguousOutcome check matches on this type), not as a definite non-creation + // that's safe to let a retry create a genuine duplicate order for. + throw new MarkMonitorOrderCreatedButUnparsableException( + "MarkMonitor returned a successful response for order creation, but its body could not be parsed", + parseException); + } + + if (order == null) + // An empty body or a literal "null" both deserialize to null without throwing - + // same "MarkMonitor confirmed creation, but we can't read the result" situation as + // the catch above, just without an exception to wrap. Must not fall through to + // order.Id below, which would throw a plain NullReferenceException that + // EnrollCertificateAsync's isAmbiguousOutcome check doesn't recognize as ambiguous. + throw new MarkMonitorOrderCreatedButUnparsableException( + "MarkMonitor returned a successful response for order creation, but its body was empty or null", + null); + + _logger.LogInformation("Certificate order {OrderId} created", order.Id); + return order; + } + + var errMsg = BuildErrorString(content); + _logger.LogError("An error has occurred while attempting to create order: {EMessage}", errMsg); + throw new Exception(errMsg); + } + catch (Exception e) + { + _logger.LogError("An error has occurred while attempting to create order: {EMessage}", e.Message); + throw; + } + finally + { + _logger.MethodExit(); + } + } + + public async Task CancelCertificateAsync(string orderId, string orgName = null) + { + _logger.MethodEntry(); + try + { + ValidateGuidFormat(orderId, nameof(orderId), "MarkMonitor order ID"); + // orderId is caller-supplied - log a CR/LF-escaped copy so an embedded CR/LF can't forge a + // fake log line (CWE-117). ValidateGuidFormat above already guarantees it's GUID-shaped + // for normal (non-exceptional) calls, but its own exception message is sanitized too, so + // this covers the exceptional path as well. + var logSafeOrderId = LogSanitizer.ForLog(orderId); + _logger.LogInformation("Cancelling certificate {CertificateId}", logSafeOrderId); + + var (success, errMsg) = + await PatchOrderActionAsync(orderId, orgName, "Cancelling", "cancel"); + if (success) + { + _logger.LogInformation("Certificate {CertificateId} has been cancelled", logSafeOrderId); + return true; + } + + _logger.LogError("An error has occurred while attempting to cancel order {CertificateId}: {EMessage}", + logSafeOrderId, errMsg); + throw new Exception(errMsg); + } + catch (Exception e) + { + _logger.LogError("An error has occurred while attempting to cancel {CertificateId}: {EMessage}", + LogSanitizer.ForLog(orderId), e.Message); + throw; + } + finally + { + _logger.MethodExit(); + } + } + + // Unlike order-create/reissue, retrying a cancel or revoke is safe - acting on an already- + // cancelled/revoked order is a no-op, not a second billable resource. Shared by + // CancelCertificateAsync and RevokeCertificateAsync, which differ only in log wording handled by + // each caller. + private async Task<(bool success, string errMsg)> PatchOrderActionAsync(string orderId, string orgName, + string ownershipGerund, string action) + { + await EnsureAuthenticatedAsync(); + + await EnsureOrderBelongsToOrganizationAsync(orderId, orgName, ownershipGerund, action); + + var url = $"{BaseUrl}/certs/v1/order/{orderId}/{action}"; + _logger.LogDebug("{Gerund} certificate at {Url}", ownershipGerund, url); + var payload = new StringContent("{}", Encoding.UTF8, "application/json"); + var response = await SendWithRetryAsync(() => _httpClient.PatchAsync(url, payload), "PATCH", url); + + _logger.LogDebug("Reading response content"); + var content = await response.Content.ReadAsStringAsync(); + return response.IsSuccessStatusCode ? (true, null) : (false, BuildErrorString(content)); + } + + public async Task ReissueCertificateAsync(string orderId, MarkMonitorReissueRequest payload) + { + _logger.MethodEntry(); + try + { + ValidateGuidFormat(orderId, nameof(orderId), "MarkMonitor order ID"); + // orderId is caller-supplied - log a CR/LF-escaped copy so an embedded CR/LF can't forge a + // fake log line (CWE-117). + var logSafeOrderId = LogSanitizer.ForLog(orderId); + _logger.LogInformation("Revoking certificate {CertificateId}", logSafeOrderId); + await EnsureAuthenticatedAsync(); + + var url = $"{BaseUrl}/certs/v1/order/{orderId}/reissue"; + _logger.LogDebug("Reissuing certificate at {Url}", url); + //convert payload to json + var jsonPayload = new StringContent( + JsonConvert.SerializeObject(payload), + Encoding.UTF8, "application/json" + ); + _logger.LogTrace("Reissue payload: {@Payload}", jsonPayload); + // Deliberately not retried, same reasoning as CreateCertificateOrder's order-create POST: + // reissue produces a new certificate, so a transport-level failure here is ambiguous + // about whether MarkMonitor already reissued before we found out. + var response = await SendAndLogAsync(() => _httpClient.PatchAsync(url, jsonPayload), "PATCH", url); + + _logger.LogDebug("Reading response content"); + var content = await response.Content.ReadAsStringAsync(); + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("Certificate {CertificateId} has been reissued", logSafeOrderId); + return true; + } + + var errMsg = BuildErrorString(content); + _logger.LogError("An error has occurred while attempting to reissue {CertificateId}: {EMessage}", + logSafeOrderId, errMsg); + throw new Exception(errMsg); + } + catch (Exception e) + { + _logger.LogError("An error has occurred while attempting to reissue {CertificateId}: {EMessage}", + LogSanitizer.ForLog(orderId), e.Message); + throw; + } + } + + /// + /// Revokes the certificate for the given order. MarkMonitor's revoke action (PATCH + /// /certs/v1/order/{id}/revoke) has no field for a revocation reason code - its request schema + /// only accepts cert/ignoreOrgCheck/additionalEmails - so the parameter + /// cannot be sent to MarkMonitor. It's accepted (rather than removed) to match + /// IAnyCAPlugin.Revoke's signature; a non-default value is logged so it's visible that the + /// reason was received but couldn't be forwarded, rather than silently dropped. + /// + public async Task RevokeCertificateAsync(string orderId, string orgName = null, uint reason = 0) + { + _logger.MethodEntry(); + try + { + ValidateGuidFormat(orderId, nameof(orderId), "MarkMonitor order ID"); + // orderId is caller-supplied - log a CR/LF-escaped copy so an embedded CR/LF can't forge a + // fake log line (CWE-117). + var logSafeOrderId = LogSanitizer.ForLog(orderId); + _logger.LogInformation("Revoking certificate associated with order {OrderId}", logSafeOrderId); + if (reason != 0) + _logger.LogWarning( + "Revocation reason {Reason} was requested for order {OrderId}, but MarkMonitor's revoke API has no field for a reason code - it will not be sent", + reason, logSafeOrderId); + + var (success, errMsg) = + await PatchOrderActionAsync(orderId, orgName, "Revoking", "revoke"); + if (success) + { + _logger.LogInformation("Certificate {CertificateId} has been revoked", logSafeOrderId); + return true; + } + + _logger.LogError("An error has occurred while attempting to revoke {CertificateId}: {EMessage}", + logSafeOrderId, errMsg); + throw new Exception(errMsg); + } + catch (Exception e) + { + _logger.LogError("An error has occurred while attempting to revoke {CertificateId}: {EMessage}", + LogSanitizer.ForLog(orderId), e.Message); + throw; + } + finally + { + _logger.MethodExit(); + } + } + + private bool TokenNeedsRefresh() => + string.IsNullOrEmpty(_bearerToken) || _tokenExpiresAtUtc == null || + _timeProvider.GetUtcNow().UtcDateTime >= _tokenExpiresAtUtc; + + private async Task EnsureAuthenticatedAsync() + { + // Double-checked locking: without the lock, two callers can both see an expired token, + // both call AuthenticateAsync concurrently, and race writing _bearerToken/_tokenExpiresAtUtc + // and the shared HttpClient's Authorization header - one of them can end up sending requests + // under the other's (or a half-written) token. + if (!TokenNeedsRefresh()) return; + + await _authLock.WaitAsync(); + try + { + if (TokenNeedsRefresh()) + { + _logger.LogDebug("No valid bearer token on hand - authenticating"); + await AuthenticateAsync(); + } + else + { + _logger.LogDebug("Bearer token was refreshed by a concurrent caller while waiting on the auth lock - reusing it"); + } + } + finally + { + _authLock.Release(); + } + } + + /// Runs an HTTP call and logs its method/URL/status code/elapsed time - none of this + /// class's call sites otherwise captured that response metadata (only AuthenticateAsync logged a + /// status code, and only at Trace), leaving no basis in this component's own logs for latency- or + /// status-code-based anomaly detection or vendor API call forensic reconstruction. + private async Task SendAndLogAsync(Func> send, string method, + string url) + { + var stopwatch = Stopwatch.StartNew(); + try + { + var response = await send(); + stopwatch.Stop(); + _logger.LogInformation("{Method} {Url} -> {StatusCode} ({ElapsedMs}ms)", method, url, + (int)response.StatusCode, stopwatch.ElapsedMilliseconds); + return response; + } + catch (Exception e) + { + // A transport-level failure (timeout, DNS failure, connection refused/reset, TLS failure) + // never produces a response at all, so the success path's own log line above never runs - + // log the method/URL/elapsed-time here too, so every one of this helper's call sites gets + // that context on failure as well, not just success. + stopwatch.Stop(); + _logger.LogError("{Method} {Url} -> failed after {ElapsedMs}ms: {EMessage}", method, url, + stopwatch.ElapsedMilliseconds, e.Message); + throw; + } + } + + /// + /// Wraps with up to attempts: + /// retries a network-level/timeout failure or an HTTP 5xx/429 response, with exponential + /// backoff (±25% jitter), honoring a 429 response's Retry-After header when present. Any other + /// 4xx is returned immediately without retrying - that's this component's own bad request, not + /// a transient failure retrying could fix. Must NOT be used for CreateCertificateOrder's + /// order-create POST (see this class's constant-level comment on why). + /// + private async Task SendWithRetryAsync(Func> send, string method, + string url, CancellationToken cancelToken = default) + { + for (var attempt = 1; ; attempt++) + { + HttpResponseMessage response; + try + { + response = await SendAndLogAsync(send, method, url); + } + catch (Exception e) when (e is HttpRequestException or TaskCanceledException && + attempt < MaxRetryAttempts) + { + var delay = ComputeRetryDelay(attempt); + _logger.LogWarning( + "{Method} {Url} failed on attempt {Attempt}/{MaxAttempts} ({EMessage}) - retrying in {DelayMs}ms", + method, url, attempt, MaxRetryAttempts, e.Message, delay.TotalMilliseconds); + await _delay(delay, cancelToken); + continue; + } + + if (response.IsSuccessStatusCode) return response; + + var statusCode = (int)response.StatusCode; + var isRetryableStatus = statusCode == 429 || statusCode >= 500; + if (!isRetryableStatus || attempt >= MaxRetryAttempts) return response; + + var retryDelay = statusCode == 429 + ? GetRetryAfterDelay(response) ?? ComputeRetryDelay(attempt) + : ComputeRetryDelay(attempt); + _logger.LogWarning( + "{Method} {Url} returned {StatusCode} on attempt {Attempt}/{MaxAttempts} - retrying in {DelayMs}ms", + method, url, statusCode, attempt, MaxRetryAttempts, retryDelay.TotalMilliseconds); + // Deliberately not disposing the discarded response here - no other call site in this + // class disposes an HttpResponseMessage either (see e.g. AuthenticateAsync/ + // ListOrganizationsAsync), so this stays consistent with that existing convention. + await _delay(retryDelay, cancelToken); + } + } + + private TimeSpan ComputeRetryDelay(int attempt) + { + var exponential = RetryBaseDelay * Math.Pow(2, attempt - 1); + // ±25% jitter so multiple retrying callers don't all wake up and retry in lockstep. + var jitterFactor = 0.75 + _retryJitter.NextDouble() * 0.5; + return exponential * jitterFactor; + } + + private TimeSpan? GetRetryAfterDelay(HttpResponseMessage response) + { + var retryAfter = response.Headers.RetryAfter; + if (retryAfter == null) return null; + + TimeSpan? delay = null; + if (retryAfter.Delta != null) delay = retryAfter.Delta; + else if (retryAfter.Date != null) + { + var delta = retryAfter.Date.Value - _timeProvider.GetUtcNow(); + delay = delta > TimeSpan.Zero ? delta : TimeSpan.Zero; + } + + if (delay == null) return null; + return TimeSpan.FromSeconds(Math.Min(delay.Value.TotalSeconds, MaxRetryAfterSeconds)); + } + + private static string BuildErrorString(string jsonString) + { + var json = JObject.Parse(jsonString); + var errorMessages = new List(); + + // Every value pulled out of MarkMonitor's response below is sanitized before it's woven into + // errorMessages - a validation API quoting back the offending value is a common pattern, and + // this component's own request could easily be why: e.g. a CSR-derived CommonName/SAN entry + // with an embedded CR/LF (CSR ASN.1 encoding doesn't prevent that). Without this, that CR/LF + // would forge a log entry (CWE-117) via BuildErrorString's result, despite the sanitization + // already applied everywhere else a requester-influenceable string reaches this component's + // logs. Sanitizing each value here - not the joined result - preserves the intentional + // Environment.NewLine separators between distinct errors below. + // MarkMonitor 400 responses: {"validations":[{"field":"cert.csr","code":"...","message":"..."}]} + if (json["validations"] is JArray validations) + foreach (var validation in validations) + { + var field = LogSanitizer.ForLog(validation["field"]?.ToString()); + var code = LogSanitizer.ForLog(validation["code"]?.ToString()); + var message = LogSanitizer.ForLog(validation["message"]?.ToString()); + errorMessages.Add($"{field}: {message} ({code})"); + } + // MarkMonitor 500 responses: {"errors":[{"code":"...","message":"..."}]} + else if (json["errors"] is JArray errors) + foreach (var error in errors) + { + var code = LogSanitizer.ForLog(error["code"]?.ToString()); + var message = LogSanitizer.ForLog(error["message"]?.ToString()); + errorMessages.Add($"{message} ({code})"); + } + else if (json["validation_messages"] != null) + foreach (var validationMessage in json["validation_messages"]) + { + var field = LogSanitizer.ForLog(validationMessage.Path); + var fieldErrors = (JObject)validationMessage.First; + + foreach (var error in fieldErrors) + { + var errorMessage = LogSanitizer.ForLog(error.Value.ToString()); + errorMessages.Add($"{field}: {errorMessage}"); + + if (error.Key == "options") + { + var options = string.Join(", ", + error.Value.ToObject>().Select(LogSanitizer.ForLog)); + errorMessages.Add($"{field} options: {options}"); + } + } + } + else if (json["detail"] != null) + errorMessages.Add(LogSanitizer.ForLog(json["detail"].ToString())); + + if (errorMessages.Any()) return string.Join(Environment.NewLine, errorMessages); + + // Unrecognized shape - order/contact payloads can carry customer PII (name, email), so + // truncate rather than dumping the full response body verbatim into an error-level log. + const int maxLength = 200; + var truncated = jsonString.Length > maxLength ? jsonString[..maxLength] + "... (truncated)" : jsonString; + return $"No recognized error format found in response: {LogSanitizer.ForLog(truncated)}"; + } + + private byte[] GetCsrBytes(string csr) + { + _logger.MethodEntry(); + try + { + _logger.LogDebug("Attempting to decode CSR string"); + // Try to decode the string from Base64 + return Convert.FromBase64String(csr); + } + catch (FormatException) + { + _logger.LogDebug("Decoding failed, assuming PEM format"); + // If decoding fails, assume the string is in PEM format + var pem = csr.Replace("-----BEGIN CERTIFICATE REQUEST-----", "") + .Replace("-----END CERTIFICATE REQUEST-----", "") + .Replace("\n", "") + .Replace("\r", "") + .Trim(); + return Convert.FromBase64String(pem); + } + finally + { + _logger.MethodExit(); + } + } + + private int MarkMonitorCertificateStatusToCAStatus(OrderContent order) + { + _logger.MethodEntry(); + if (order == null || string.IsNullOrEmpty(order.Status)) + { + _logger.LogError("MarkMonitor order is null or status is empty"); + return (int)EndEntityStatus.FAILED; + } + + + _logger.LogDebug("MarkMonitor order {OrderId} status: {OrderStatus}", order.Id, order.Status); + if ( + order.Status.Equals(OrderStatus.DigiPending.GetDescription(), StringComparison.OrdinalIgnoreCase) || + order.Status.Equals(OrderStatus.DigiProcessing.GetDescription(), StringComparison.OrdinalIgnoreCase) || + order.Status.Equals(OrderStatus.DigiReissuePending.GetDescription(), StringComparison.OrdinalIgnoreCase) || + order.Status.Equals(OrderStatus.DigiWaitingPickup.GetDescription(), StringComparison.OrdinalIgnoreCase) || + order.Status.Equals(OrderStatus.ReissuePending.GetDescription(), StringComparison.OrdinalIgnoreCase) || + order.Status.Equals(OrderStatus.DigiNeedsApproval.GetDescription(), StringComparison.OrdinalIgnoreCase) || + order.Status.Equals(OrderStatus.ReissueRequestPending.GetDescription(), StringComparison.OrdinalIgnoreCase) + ) + { + _logger.LogInformation("MarkMonitor order {OrderId} status 'IN PROCESS'", order.Id); + _logger.LogInformation( + "MarkMonitor order {OrderId} may still be in process and/or require manual intervention", order.Id); + return (int)EndEntityStatus.INPROCESS; + } + + if ( + order.Status.Equals(OrderStatus.DigiRevoked.GetDescription(), StringComparison.OrdinalIgnoreCase) + ) + { + _logger.LogInformation("MarkMonitor order {OrderId} status 'REVOKED'", order.Id); + _logger.MethodExit(); + return (int)EndEntityStatus.REVOKED; + } + + + if (order.Status.Equals(OrderStatus.DigiIssued.GetDescription(), StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("MarkMonitor order {OrderId} status 'GENERATED'", order.Id); + _logger.MethodExit(); + return (int)EndEntityStatus.GENERATED; + } + + + if ( + order.Status.Equals(OrderStatus.DigiFailed.GetDescription(), StringComparison.OrdinalIgnoreCase) || + order.Status.Equals(OrderStatus.DigiReissueFailed.GetDescription(), StringComparison.OrdinalIgnoreCase) + ) + { + _logger.LogError("MarkMonitor order {OrderId} status 'FAILED'", order.Id); + _logger.MethodExit(); + return (int)EndEntityStatus.FAILED; + } + + + if ( + order.Status.Equals(OrderStatus.DigiCanceled.GetDescription(), StringComparison.OrdinalIgnoreCase) || + order.Status.Equals(OrderStatus.DigiRejected.GetDescription(), StringComparison.OrdinalIgnoreCase) || + order.Status.Equals(OrderStatus.DigiExpired.GetDescription(), StringComparison.OrdinalIgnoreCase) || + order.Status.Equals(OrderStatus.DigiNeedsCsr.GetDescription(), StringComparison.OrdinalIgnoreCase) + ) + { + _logger.LogInformation("MarkMonitor order {OrderId} status 'CANCELLED'", order.Id); + _logger.MethodExit(); + return (int)EndEntityStatus.CANCELLED; + } + + + if ( + order.Status.Equals(OrderStatus.Created.GetDescription(), StringComparison.OrdinalIgnoreCase) + ) + { + // EndEntityStatus.INITIALIZED is not what the AnyGatewayREST framework treats as + // "accepted, still pending" - that's EXTERNALVALIDATION. Returning INITIALIZED here + // caused the gateway to report a hard enrollment failure for an order that had, in + // fact, been created successfully at MarkMonitor and was simply awaiting DCV/issuance + // (confirmed against a real AnyGatewayREST + Command deployment - github issue #2). + _logger.LogInformation("MarkMonitor order {OrderId} status 'CREATED' - pending external validation", + order.Id); + _logger.MethodExit(); + return (int)EndEntityStatus.EXTERNALVALIDATION; + } + + _logger.LogError("MarkMonitor order {OrderId} status could not be dettermined defaulting to 'FAILED'", + order.Id); + _logger.MethodExit(); + return (int)EndEntityStatus.FAILED; + } +} + +public class ConfigurationValidationException : Exception +{ + public ConfigurationValidationException() + { + } + + public ConfigurationValidationException(string message) + : base(message) + { + } + + public ConfigurationValidationException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// Thrown when MarkMonitor's order-create response indicates success (2xx) but its body +/// can't be parsed - the order was definitely created despite the failure, so EnrollCertificateAsync +/// must treat this the same as an ambiguous network-level failure, not as a definite non-creation +/// that's safe to let a retry duplicate. +public class MarkMonitorOrderCreatedButUnparsableException : Exception +{ + public MarkMonitorOrderCreatedButUnparsableException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/markmonitor-caplugin/LogSanitizer.cs b/markmonitor-caplugin/LogSanitizer.cs new file mode 100644 index 0000000..7c578d3 --- /dev/null +++ b/markmonitor-caplugin/LogSanitizer.cs @@ -0,0 +1,27 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor; + +/// +/// Escapes CR/LF out of a value before it's interpolated into a log message. Subject/CN (and other +/// CSR-derived fields) are fully controlled by the certificate requester - an embedded CR/LF would +/// otherwise render as real line breaks in a text-based log sink, letting a requester forge a +/// fabricated log entry (CWE-117) that could be mistaken for a genuine, unrelated line by anyone +/// relying on this plugin's logs to reconstruct certificate-issuance history. +/// +internal static class LogSanitizer +{ + public static string ForLog(string value) => value?.Replace("\r", "\\r").Replace("\n", "\\n"); +} diff --git a/markmonitor-caplugin/MarkMonitorCAConnector.cs b/markmonitor-caplugin/MarkMonitorCAConnector.cs new file mode 100644 index 0000000..6194d78 --- /dev/null +++ b/markmonitor-caplugin/MarkMonitorCAConnector.cs @@ -0,0 +1,662 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Concurrent; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Client; +using Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; +using Keyfactor.Logging; +using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using MarkMonitorConstants = Keyfactor.Extensions.CAPlugin.MarkMonitor.MarkMonitorCAPluginConfig; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor; + +public class MarkMonitorCAPlugin : IAnyCAPlugin +{ + private readonly ILogger _logger = LogHandler.GetClassLogger(); + private ICertificateDataReader _certificateDataReader; + private MarkMonitorConfig _config; + private MarkMonitorClient Client; + private bool _markMonitorClientWasInjected = false; + private MarkMonitorClient _cachedClient; + private readonly SemaphoreSlim _clientLock = new(1, 1); + + public MarkMonitorCAPlugin() + { + // Explicit default constructor + } + + public MarkMonitorCAPlugin(MarkMonitorClient client) + { + Client = client; + _markMonitorClientWasInjected = true; + } + + public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) + { + _logger.MethodEntry(); + _certificateDataReader = certificateDataReader; + var rawConfig = JsonConvert.SerializeObject(configProvider.CAConnectionData); + _config = JsonConvert.DeserializeObject(rawConfig); + _logger.MethodExit(); + } + + + private void logConfig() + { + _logger.MethodEntry(); + _logger.LogInformation("MarkMonitorCAPlugin config baseUrl: {Config}", _config.BaseUrl); + LogMaskedConfigValue("apiKey", _config.ApiKey); + // Unlike apiKey/apiPassword, apiUsername is not a secret (GetPluginAnnotations marks it + // Hidden=false) and is the only field identifying which MarkMonitor service account this CA + // connector instance uses - log it plainly rather than masking it, so an auditor reviewing + // this component's own logs can attribute actions to a specific credential/identity, including + // when multiple CA connector instances (different service accounts) share one log sink. + if (_config.ApiUsername is { Length: > 0 }) + _logger.LogInformation("MarkMonitorCAPlugin config apiUsername: {Config}", _config.ApiUsername); + else + _logger.LogError("MarkMonitorCAPlugin config apiUsername: NOT SET"); + LogMaskedConfigValue("apiPassword", _config.ApiPassword); + _logger.LogInformation("MarkMonitorCAPlugin config orgName: {Config}", _config.OrgName); + _logger.MethodExit(); + } + + private void LogMaskedConfigValue(string label, string value) + { + if (value is { Length: > 0 }) + _logger.LogInformation("MarkMonitorCAPlugin config {Label}: {Config}", label, new string('*', 32)); + else + _logger.LogError("MarkMonitorCAPlugin config {Label}: NOT SET", label); + } + + public async Task GetSingleRecord(string caRequestId) + { + _logger.MethodEntry(); + // caRequestId is caller-supplied and only validated as a GUID deeper inside MarkMonitorClient + // - log a CR/LF-escaped copy here so an embedded CR/LF can't forge a fake log line (CWE-117) + // before that validation ever runs. + var logSafeCaRequestId = LogSanitizer.ForLog(caRequestId); + try + { + var client = await CreateAndAuthenticateClientAsync(); + _logger.LogInformation("Getting order details for CARequestID: {CARequestID}", logSafeCaRequestId); + var order = await client.GetSingleOrderAsync(caRequestId); + _logger.LogInformation("Order details retrieved for CARequestID: {CARequestID}", logSafeCaRequestId); + return order; + } + catch (Exception e) + { + _logger.LogError("Failed to get order details for CARequestID {CARequestID}: {EMessage}", + logSafeCaRequestId, e.Message); + throw; + } + finally + { + _logger.MethodExit(); + } + + } + + public async Task Synchronize(BlockingCollection blockingBuffer, DateTime? lastSync, + bool fullSync, CancellationToken cancelToken) + { + _logger.MethodEntry(); + + try + { + _logger.LogInformation(fullSync + ? "Performing a full CA synchronization" + : "Performing a partial CA synchronization"); + + logConfig(); + + _logger.LogDebug("Calling CreateAndAuthenticateClientAsync"); + var client = await CreateAndAuthenticateClientAsync(); + _logger.LogDebug("CreateAndAuthenticateClientAsync completed"); + + _logger.LogInformation("Attempting to synchronize certificates with MarkMonitor API"); + // Command's own fullSync flag forces a complete resync same as the ForceCompleteSync + // connection setting does - either one bypasses the skip-unchanged optimization. + var forceCompleteSync = fullSync || _config.ForceCompleteSync; + var certificates = await client.GetCertificateInventoryAsync("", "", _config.PageSize, blockingBuffer, + cancelToken, _certificateDataReader, forceCompleteSync); + _logger.LogDebug("Synchronized {Certificates} certificates", certificates); + + // Check for cancellation after operation + // cancelToken.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Synchronization canceled"); + throw; // Rethrow the cancellation exception to ensure it's propagated + } + catch (Exception ex) + { + _logger.LogError("An error occurred during synchronization: {ExMessage}", ex.Message); + throw; + } + finally + { + _logger.MethodExit(); + } + } + + public async Task Revoke(string orderId, string hexSerialNumber, uint revocationReason) + { + _logger.MethodEntry(); + // orderId/hexSerialNumber are caller-supplied and only validated as a GUID deeper inside + // MarkMonitorClient - log CR/LF-escaped copies here so an embedded CR/LF can't forge a fake + // log line (CWE-117) before that validation ever runs. + var logSafeOrderId = LogSanitizer.ForLog(orderId); + var logSafeHexSerialNumber = LogSanitizer.ForLog(hexSerialNumber); + try + { + EnsureOrgNameConfigured(); + + _logger.LogInformation( + "Revoking certificate with CARequestID: {CaRequestId}, SerialNumber: {HexSerialNumber}, Reason: {RevocationReason}", + logSafeOrderId, logSafeHexSerialNumber, revocationReason); + + var client = await CreateAndAuthenticateClientAsync(); + + _logger.LogInformation("Attempting to revoke certificate with CARequestID: {CaRequestId}", logSafeOrderId); + + var revokeResult = await client.RevokeCertificateAsync(orderId, _config.OrgName, revocationReason); + + if (revokeResult) return (int)EndEntityStatus.REVOKED; + + throw new Exception("Unable to revoke certificate associated with order ID: " + orderId); + } + catch (Exception e) + { + _logger.LogError("Revoke failed for order {OrderId}: {EMessage}", logSafeOrderId, e.Message); + throw new Exception($"Revoke Failed with message {e.Message}"); + } + finally + { + _logger.MethodExit(); + } + } + + public async Task Enroll(string csr, string subject, Dictionary san, + EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) + { + _logger.MethodEntry(); + // Subject is fully requester-controlled (it comes straight off the submitted CSR) - log a + // CR/LF-escaped copy everywhere below so an embedded CR/LF can't forge a fake log line + // (CWE-117). The real, unsanitized subject is still what's actually used for enrollment. + var logSafeSubject = LogSanitizer.ForLog(subject); + try + { + _logger.LogInformation("Enrolling certificate `{Subject}` with MarkMonitor", logSafeSubject); + + var client = await CreateAndAuthenticateClientAsync(); + + _logger.LogInformation("Performing an Enrollment"); + _logger.LogTrace("CSR: {Csr}", LogSanitizer.ForLog(csr)); + _logger.LogTrace("Subject: {Subject}", logSafeSubject); + _logger.LogTrace("SAN: {San}", JsonConvert.SerializeObject(san)); + _logger.LogTrace("Product ID: {ProductId}", productInfo.ProductID); + + var enrollResult = await client.EnrollCertificateAsync(csr, subject, san, productInfo.ProductID, + productInfo.ProductParameters, _config); + + _logger.LogTrace("Enrollment result: {EnrollResult}", JsonConvert.SerializeObject(enrollResult)); + _logger.LogInformation("Enrollment completed successfully for subject: {Subject}", logSafeSubject); + + if (enrollmentType == EnrollmentType.RenewOrReissue) + await RevokePriorCertificateIfPresentAsync(client, productInfo, subject, enrollResult.CARequestID); + + return enrollResult; + } + catch (Exception e) + { + _logger.LogError("Enrollment failed for subject {Subject}: {EMessage}", logSafeSubject, e.Message); + throw; + } + finally + { + _logger.MethodExit(); + } + } + + private const int DefaultRenewalWindowDays = 90; + + /// + /// For a RenewOrReissue enrollment, the AnyGateway core framework passes the prior + /// certificate's serial number in productInfo.ProductParameters["PriorCertSN"]. Resolves it to a + /// CARequestID via the injected ICertificateDataReader and revokes it now that the replacement + /// certificate has issued successfully - but only when the prior certificate is actually within + /// its RenewalWindowDays template parameter (default 90) of expiring; certinext's model, + /// applied here since MarkMonitor likewise has no in-place "renew" endpoint, so the window gates + /// revoke behavior rather than endpoint choice. A prior cert with substantial life left outside + /// that window is left unrevoked - this "renewal" is instead treated like a plain new issuance. + /// Falls back to treating the enrollment as a plain new issuance (no revoke attempted) if + /// PriorCertSN is missing or can't be resolved - a failure to revoke the old certificate should + /// not fail delivery of the new one. + /// + /// Case-insensitive enrollment product-parameter lookup, matching every other + /// template-parameter lookup in this file (Command's parameter keys aren't guaranteed to arrive + /// in any particular casing). + private static string GetProductParameter(Dictionary productParameters, string key) => + productParameters?.FirstOrDefault(kv => string.Equals(kv.Key, key, StringComparison.OrdinalIgnoreCase)).Value; + + private async Task RevokePriorCertificateIfPresentAsync(MarkMonitorClient client, + EnrollmentProductInfo productInfo, string subject, string newCaRequestId) + { + var priorCertSn = GetProductParameter(productInfo.ProductParameters, "PriorCertSN"); + // PriorCertSN is a caller-supplied enrollment product parameter - log a CR/LF-escaped copy so + // an embedded CR/LF can't forge a fake log line (CWE-117), matching every other caller-supplied + // value in this file. + var logSafePriorCertSn = LogSanitizer.ForLog(priorCertSn); + + if (string.IsNullOrWhiteSpace(priorCertSn)) + { + _logger.LogWarning( + "Enrollment for {Subject} was requested as RenewOrReissue but no PriorCertSN was provided - treating it as a new enrollment", + LogSanitizer.ForLog(subject)); + return; + } + + var priorRequestId = await _certificateDataReader.GetRequestIDBySerialNumber(priorCertSn); + if (string.IsNullOrWhiteSpace(priorRequestId)) + { + _logger.LogWarning( + "Could not resolve a CARequestID for PriorCertSN {PriorCertSn} - the prior certificate will not be revoked", + logSafePriorCertSn); + return; + } + + var renewalWindowDays = ParseRenewalWindowDays(productInfo.ProductParameters); + var priorCertExpiration = _certificateDataReader.GetExpirationDateByRequestId(priorRequestId); + if (priorCertExpiration != null) + { + var daysUntilExpiration = (priorCertExpiration.Value - DateTime.UtcNow).TotalDays; + if (daysUntilExpiration > renewalWindowDays) + { + _logger.LogInformation( + "Prior certificate {PriorRequestId} does not expire for {DaysUntilExpiration:F0} more day(s), outside the configured RenewalWindowDays ({RenewalWindowDays}) - leaving it unrevoked and treating this enrollment like a plain new issuance", + priorRequestId, daysUntilExpiration, renewalWindowDays); + return; + } + } + // Expiration unresolvable (null): fall back to the pre-existing behavior (always revoke) + // rather than silently changing behavior when there isn't enough data to apply the new gate. + + try + { + EnsureOrgNameConfigured(); + + _logger.LogInformation( + "Revoking prior certificate {PriorRequestId} (serial {PriorCertSn}) after it was replaced by {NewCaRequestId}", + priorRequestId, logSafePriorCertSn, newCaRequestId); + await client.RevokeCertificateAsync(priorRequestId, _config.OrgName); + } + catch (Exception e) + { + _logger.LogError( + "Failed to revoke prior certificate {PriorRequestId} after it was replaced by {NewCaRequestId}: {EMessage}", + priorRequestId, newCaRequestId, e.Message); + } + } + + /// Parses the RenewalWindowDays template parameter (case-insensitive key, matching + /// every other enrollment parameter lookup in this file); absent or invalid (non-positive, + /// non-numeric) falls back to rather than failing the + /// enrollment over a template misconfiguration. A value that's present but rejected is logged - + /// unlike a value that's simply absent - since this silently changes a security-relevant revoke + /// decision and an administrator who mistyped it would otherwise have no signal from the gateway + /// logs that their configured window was never actually applied. + private int ParseRenewalWindowDays(Dictionary productParameters) + { + var raw = GetProductParameter(productParameters, "RenewalWindowDays"); + if (string.IsNullOrWhiteSpace(raw)) return DefaultRenewalWindowDays; + + if (int.TryParse(raw, out var parsed) && parsed > 0) return parsed; + + _logger.LogWarning( + "Invalid RenewalWindowDays value '{RenewalWindowDays}' - must be a positive integer; falling back to the default of {DefaultRenewalWindowDays} day(s)", + LogSanitizer.ForLog(raw), DefaultRenewalWindowDays); + return DefaultRenewalWindowDays; + } + + /// + /// RevokeCertificateAsync's cross-org ownership check is skipped (not rejected) when its + /// orgName parameter is blank - a deliberate allowance for ad-hoc/manual callers that don't + /// scope by organization. Initialize() never re-validates the deserialized config, so without + /// this guard a plugin instance loaded with a blank OrgId would silently revoke without any + /// organization check at all, on every Revoke call this connector makes. + /// + private void EnsureOrgNameConfigured() + { + if (string.IsNullOrWhiteSpace(_config.OrgName)) + throw new ConfigurationValidationException( + "MarkMonitor OrgId is not configured - refusing to revoke without an organization to verify ownership against"); + } + + public async Task Ping() + { + _logger.MethodEntry(); + try + { + _logger.LogInformation("Attempting to authenticate with MarkMonitor API"); + var client = await CreateAndAuthenticateClientAsync(); + + if (client == null) throw new Exception("Error attempting to ping MarkMonitor"); + + _logger.LogInformation("Attempting to list organizations"); + // ListOrganizationsAsync's pagination loop has no early exit - it always fetches every + // page up to TotalPages regardless of what the caller actually needs. A page size of 1 + // (an earlier version of this fix) backfires badly here: TotalPages becomes the account's + // total organization count, turning this existence check into one sequential HTTP request + // per organization. Page size 100 - the same size already used for org-name resolution - + // keeps this to a single request for the common case instead. + var orgs = await client.ListOrganizationsAsync(0, 100); + // CreateAndAuthenticateClientAsync deliberately does NOT authenticate eagerly (see its own + // doc comment) - it just builds/caches the client wrapper. The real authentication happens + // lazily inside ListOrganizationsAsync above (via EnsureAuthenticatedAsync, properly + // serialized against every other call path by _authLock) - only log success now that it + // has actually completed, rather than before any credential had been checked. (A previous + // version of this fix called client.AuthenticateAsync() directly here to get the ordering + // right, but that bypassed _authLock entirely and could race a concurrent Enroll/Revoke/ + // Synchronize call's own authentication on the same shared HttpClient - reordering instead + // of calling AuthenticateAsync directly avoids that race altogether.) + _logger.LogInformation("Authentication with MarkMonitor API successful"); + + if (orgs == null || !orgs.Any()) + throw new Exception("Unable to ping MarkMonitor API, or no MarkMonitor organization exist"); + _logger.LogInformation("Successfully pinged MarkMonitor API"); + } + catch (Exception e) + { + _logger.LogError("There was an error contacting MarkMonitor: {EMessage}", e.Message); + throw new Exception($"Error attempting to ping MarkMonitor: {e.Message}.", e); + } + finally + { + _logger.MethodExit(); + } + } + + public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) + { + _logger.MethodEntry(); + _logger.LogInformation("Validating CA Connection Info"); + + var errors = new List(); + + _logger.LogDebug("Checking the API Key"); + var apiKey = connectionInfo.TryGetValue(MarkMonitorConstants.ConfigConstants.ApiKey, out var aKey) + ? (string)aKey + : string.Empty; + if (string.IsNullOrWhiteSpace(apiKey)) + errors.Add($"A valid `{MarkMonitorConstants.ConfigConstants.ApiKey} is required"); + else _logger.LogDebug($"{MarkMonitorConstants.ConfigConstants.ApiKey} is set"); + + _logger.LogDebug("Checking the API service account password"); + var apiPassword = connectionInfo.TryGetValue(MarkMonitorConstants.ConfigConstants.ApiPassword, out var aPass) + ? (string)aPass + : string.Empty; + if (string.IsNullOrWhiteSpace(apiPassword)) + errors.Add($"A valid service account `{MarkMonitorConstants.ConfigConstants.ApiPassword}` is required"); + else _logger.LogDebug($"{MarkMonitorConstants.ConfigConstants.ApiPassword} is set"); + + _logger.LogDebug("Checking the API service account username"); + var apiUsername = connectionInfo.TryGetValue(MarkMonitorConstants.ConfigConstants.ApiUsername, out var aUser) + ? (string)aUser + : string.Empty; + if (string.IsNullOrWhiteSpace(apiUsername)) + errors.Add($"A valid service account `{MarkMonitorConstants.ConfigConstants.ApiUsername}` is required"); + else _logger.LogDebug($"{MarkMonitorConstants.ConfigConstants.ApiUsername} is set"); + + _logger.LogDebug("Checking the API base URL"); + var baseURL = connectionInfo.TryGetValue(MarkMonitorConstants.ConfigConstants.BaseUrl, out var aUrl) + ? (string)aUrl + : string.Empty; + if (string.IsNullOrWhiteSpace(baseURL)) baseURL = "https://api.markmonitor.com"; + else if (!baseURL.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + errors.Add("The Base URL must start with https:// - credentials and the bearer token are sent to it"); + else _logger.LogDebug($"{MarkMonitorConstants.ConfigConstants.BaseUrl} is set"); + _logger.LogTrace("MarkMonitor API Base URL: {BaseURL}", baseURL); + + _logger.LogDebug("Checking the Organization Name"); + var orgName = connectionInfo.TryGetValue(MarkMonitorConstants.ConfigConstants.OrgName, out var aOrg) + ? (string)aOrg + : string.Empty; + if (string.IsNullOrWhiteSpace(orgName)) errors.Add("A valid Organization name is required"); + else _logger.LogDebug($"{MarkMonitorConstants.ConfigConstants.OrgName} is set"); + _logger.LogTrace("MarkMonitor Organization Name: {OrgName}", orgName); + if (errors.Any()) ThrowValidationException(errors); + + // Enabled's own documented purpose (GetPluginAnnotations' Comments) is letting an admin + // create the CA connector before real MarkMonitor credentials are available - a workflow the + // field-presence/format checks above already accommodate (they only require *some* + // syntactically-valid values, not working ones). The live-connectivity check below must not + // undermine that pre-existing capability by requiring real connectivity even for a + // deliberately-disabled, not-yet-configured connector. + var enabled = !connectionInfo.TryGetValue(MarkMonitorConstants.ConfigConstants.Enabled, out var aEnabled) || + aEnabled is not bool enabledFlag || enabledFlag; + if (!enabled) + { + _logger.LogInformation( + "CA connector is disabled - skipping the live MarkMonitor connectivity check"); + _logger.LogInformation("CA Connection Info validated successfully"); + _logger.MethodExit(); + return; + } + + // The aggregated checks above only confirm the fields are present and well-formed - not that + // they're actually valid MarkMonitor credentials. Build a transient client from the submitted + // connectionInfo itself (never _cachedClient, which may hold stale/different creds from a + // previous save) so validation reflects exactly what's about to be saved. A test-injected + // client (see the constructor overload) is reused as-is instead, the same seam every other + // method in this class already relies on for testability - and it must not be disposed here, + // since its lifecycle belongs to whoever injected it, not to this one validation call. + MarkMonitorConfig tempConfig = null; + MarkMonitorClient tempClient = _markMonitorClientWasInjected ? Client : null; + try + { + if (!_markMonitorClientWasInjected) + { + try + { + var rawConfig = JsonConvert.SerializeObject(connectionInfo); + tempConfig = JsonConvert.DeserializeObject(rawConfig); + tempConfig.BaseUrl = baseURL; // the resolved effective value (blank -> the default above) + tempClient = BuildClient(tempConfig); + } + catch (Exception e) + { + // A field the aggregated checks above don't cover (e.g. a non-numeric value for + // one of the Number-typed fields) can fail deserialization here - caught and + // sanitized like every other failure mode in this method, rather than letting a + // raw JsonSerializationException (which can embed field paths/values) escape + // unlogged. e.Message itself echoes the rejected connectionInfo value verbatim + // (e.g. "Could not convert string to integer: ") - sanitized here too, so + // an embedded CR/LF in that value can't forge a fake log line (CWE-117), matching + // every other caller-supplied value logged in this file. + _logger.LogError("CA connection validation failed while parsing the submitted configuration: {EMessage}", + LogSanitizer.ForLog(e.Message)); + throw new AnyCAValidationException( + "The submitted configuration could not be parsed. See gateway logs for details."); + } + } + + try + { + await tempClient.AuthenticateAsync(); + } + catch (Exception e) + { + // The real exception/response detail is deliberately not forwarded to the UI - it may + // carry HTTP response fragments, headers, or other transport-layer detail. + _logger.LogError("CA connection live validation failed during authentication: {EMessage}", + e.Message); + throw new AnyCAValidationException( + "Authentication failed with the submitted MarkMonitor credentials. See gateway logs for details."); + } + + try + { + var orgs = await tempClient.ListOrganizationsAsync(0, 1); + if (orgs == null || orgs.Count == 0) + throw new Exception("No MarkMonitor organizations are visible to the submitted credentials"); + } + catch (Exception e) + { + _logger.LogError("CA connection live validation failed while listing organizations: {EMessage}", + e.Message); + throw new AnyCAValidationException( + "Authenticated with MarkMonitor, but listing organizations failed. See gateway logs for details."); + } + } + finally + { + if (!_markMonitorClientWasInjected) tempClient?.Dispose(); + // Best-effort credential scrubbing: blank out the secret fields on the transient config so + // they aren't reachable from this now-unreferenced object after this method returns. Not a + // hard guarantee (the runtime may already have copied them elsewhere), but removes the most + // obvious post-validation reference chain - certinext's pattern. + if (tempConfig != null) + { + tempConfig.ApiKey = string.Empty; + tempConfig.ApiPassword = string.Empty; + } + } + + _logger.LogInformation("CA Connection Info validated successfully"); + _logger.MethodExit(); + } + + // Considered validating MarkmonitorContact/MarkmonitorGroup here eagerly (at template-save + // time) instead of the current behavior - a typo'd value logs a warning and silently falls back + // to a default at enroll time. Deferred: doing so would mean making live MarkMonitor API calls + // during template save (no other Validate* method in this codebase does that), coupling template + // configuration to MarkMonitor's availability/latency, and duplicating the resolution logic + // that already lives in EnrollCertificateAsync. That's a real product tradeoff (fail fast on + // template save vs. graceful degradation at enroll time) rather than a straightforward bug fix. + public Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) + { + _logger.MethodEntry(); + + // Unlike MarkmonitorContact/MarkmonitorGroup above, this is a cheap static check - no live + // MarkMonitor call - so there's no tradeoff in failing fast on it at template-save time. + // Enum.TryParse alone isn't enough: it happily "succeeds" for any numeric string that fits + // the underlying int type even when no member is actually defined for that value (e.g. "20" + // for a 12-member enum) - Enum.IsDefined is the check that actually enforces membership. + if (!Enum.TryParse(productInfo.ProductID, out var parsedProductId) || + !Enum.IsDefined(typeof(CertOrderTypes), parsedProductId)) + throw new AnyCAValidationException( + $"'{LogSanitizer.ForLog(productInfo.ProductID)}' is not a valid MarkMonitor product ID. Valid values are: {string.Join(", ", Enum.GetNames(typeof(CertOrderTypes)))}"); + + _logger.LogInformation("Product info validated successfully"); + _logger.MethodExit(); + return Task.CompletedTask; + } + + public Dictionary GetCAConnectorAnnotations() + { + _logger.MethodEntry(); + try + { + _logger.LogInformation("Retrieving CA Connector annotations"); + return MarkMonitorConstants.GetPluginAnnotations(); + } + finally + { + _logger.MethodExit(); + } + + } + + public Dictionary GetTemplateParameterAnnotations() + { + _logger.MethodEntry(); + try + { + _logger.LogInformation("Retrieving template parameter annotations"); + return MarkMonitorConstants.GetTemplateParameterAnnotations(); + } + finally + { + _logger.MethodExit(); + } + + } + + public List GetProductIds() + { + // return list of CertOrderTypes Enum values + _logger.MethodEntry(); + try + { + _logger.LogInformation("Retrieving product IDs from CertOrderTypes Enum"); + return Enum.GetNames(typeof(CertOrderTypes)).ToList(); + } + finally + { + _logger.MethodExit(); + } + + } + + /// Builds a real MarkMonitorClient from the given config - the one place this + /// connector's own construction argument list lives, shared by the cached-client path + /// () and the transient one + /// (). + private static MarkMonitorClient BuildClient(MarkMonitorConfig config) => + new(config.BaseUrl, config.ApiKey, config.ApiUsername, config.ApiPassword, true, + timeoutSeconds: config.TimeoutSeconds); + + /// + /// Returns a single MarkMonitorClient shared for the lifetime of this plugin instance, building + /// it (or adopting an injected one) on first use only. Each of MarkMonitorClient's own methods + /// authenticates/re-authenticates itself lazily as needed, so this method does not need to - and + /// deliberately does not - force an eager authentication call on every invocation. + /// + internal async Task CreateAndAuthenticateClientAsync() + { + _logger.MethodEntry(); + try + { + if (_cachedClient != null) return _cachedClient; + + await _clientLock.WaitAsync(); + try + { + _cachedClient ??= _markMonitorClientWasInjected ? Client : BuildClient(_config); + } + finally + { + _clientLock.Release(); + } + + return _cachedClient; + } + finally + { + _logger.MethodExit(); + } + } + + private void ThrowValidationException(List errors) + { + _logger.MethodEntry(); + var validationMsg = $"Validation errors:\n{string.Join("\n", errors)}"; + _logger.LogError("{Errors}",validationMsg); + throw new AnyCAValidationException(validationMsg); + } +} \ No newline at end of file diff --git a/markmonitor-caplugin/MarkMonitorCAPluginConfig.cs b/markmonitor-caplugin/MarkMonitorCAPluginConfig.cs new file mode 100644 index 0000000..e176f12 --- /dev/null +++ b/markmonitor-caplugin/MarkMonitorCAPluginConfig.cs @@ -0,0 +1,278 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Keyfactor.AnyGateway.Extensions; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor; + +/// +/// Provides configuration and annotation details for the MarkMonitor CA Plugin. +/// +public class MarkMonitorCAPluginConfig +{ + /// + /// Returns a dictionary of plugin configuration property annotations. + /// + /// Dictionary mapping property names to their configuration info. + public static Dictionary GetPluginAnnotations() + { + return new Dictionary + { + [ConfigConstants.ApiKey] = new() + { + Comments = "The API Key for the MarkMonitor API", + Hidden = true, + DefaultValue = "", + Type = "String" + }, + [ConfigConstants.ApiUsername] = new() + { + Comments = "Username for the MarkMonitor API service account", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + [ConfigConstants.ApiPassword] = new() + { + Comments = "Password for the MarkMonitor API service account", + Hidden = true, + DefaultValue = "", + Type = "String" + }, + [ConfigConstants.BaseUrl] = new() + { + Comments = + "The Base URL for the MarkMonitor API - Usually either https://api.markmonitor.com", + Hidden = false, + DefaultValue = "https://api.markmonitor.com", + Type = "String" + }, + [ConfigConstants.OrgName] = new() + { + Comments = + "The name of the MarkMonitor Organization to use for the API calls (ex: MarkMonitor). You can also use the Organization ID in GUID format.", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + [ConfigConstants.Enabled] = new() + { + Comments = + "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available.", + Hidden = false, + DefaultValue = true, + Type = "Boolean" + }, + [ConfigConstants.TimeoutSeconds] = new() + { + Comments = "The HTTP request timeout, in seconds, for calls to the MarkMonitor API. Default is 120.", + Hidden = false, + DefaultValue = 120, + Type = "Number" + }, + [ConfigConstants.PageSize] = new() + { + Comments = + "The number of certificate orders requested per page during synchronization (1-500). Default is 100.", + Hidden = false, + DefaultValue = 100, + Type = "Number" + }, + [ConfigConstants.ForceCompleteSync] = new() + { + Comments = + "When true, bypasses the skip-unchanged optimization and re-emits every order on every synchronization. Default is false.", + Hidden = false, + DefaultValue = false, + Type = "Boolean" + }, + [ConfigConstants.PickupRetries] = new() + { + Comments = + "How many times to poll a freshly-created order for issuance before returning it in its still-pending state. 0 disables polling. Default is 5.", + Hidden = false, + DefaultValue = 5, + Type = "Number" + }, + [ConfigConstants.PickupDelaySeconds] = new() + { + Comments = "The delay, in seconds, between issuance pickup polls. Default is 10.", + Hidden = false, + DefaultValue = 10, + Type = "Number" + } + }; + } + + /// + /// Returns a dictionary of template parameter annotations for certificate enrollment. + /// + /// Dictionary mapping template parameter names to their configuration info. + public static Dictionary GetTemplateParameterAnnotations() + { + return new Dictionary + { + [EnrollmentConfigConstants.AdditionalEmails] = new() + { + Comments = + "List of 0 or more comma separated email addresses to send the certificate to via email after generation.", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + [EnrollmentConfigConstants.MarkmonitorGroup] = new() + { + Comments = "The name or GUID of a Markmonitor group to use for the certificate request.", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + [EnrollmentConfigConstants.MarkmonitorContact] = new() + { + Comments = + "The name or GUID of a Markmonitor contact to use for the certificate request. Will use default Markmonitor organization contact if not specified.", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + [EnrollmentConfigConstants.DCVMethod] = new() + { + Comments = + "The method to use for Domain Control Validation (DCV). Valid values are EMAIL, DNS_CNAME_TOKEN, HTTP_TOKEN, DNS_TXT_TOKEN. Default is EMAIL.", + Hidden = false, + DefaultValue = "EMAIL", + Type = "String" + }, + [EnrollmentConfigConstants.Comments] = new() + { + Comments = "Comments to attach to the MarkMonitor order. Default is \"Requested via Keyfactor Command\".", + Hidden = false, + DefaultValue = "Requested via Keyfactor Command", + Type = "String" + }, + [EnrollmentConfigConstants.Locale] = new() + { + Comments = "Locale to use for the MarkMonitor order. Default is \"en\".", + Hidden = false, + DefaultValue = "en", + Type = "String" + }, + [EnrollmentConfigConstants.Provider] = new() + { + Comments = "The certificate provider to use for the order. Default is \"DIGICERT\" (currently the only provider MarkMonitor's API supports).", + Hidden = false, + DefaultValue = "DIGICERT", + Type = "String" + }, + [EnrollmentConfigConstants.RenewalWindowDays] = new() + { + Comments = + "For a RenewOrReissue enrollment, how many days before its expiration a prior certificate must be within before it is revoked after being replaced. Outside this window, the prior certificate is left unrevoked and the request is treated like a plain new issuance. Default is 90.", + Hidden = false, + DefaultValue = 90, + Type = "Number" + } + }; + } + + /// + /// Contains constant keys for plugin configuration properties. + /// + public class ConfigConstants + { + /// + /// The API key property name. + /// + public const string ApiKey = "ApiKey"; + /// + /// The API password property name. + /// + public const string ApiPassword = "Password"; + /// + /// The API username property name. + /// + public const string ApiUsername = "Username"; + /// + /// The base URL property name. + /// + public const string BaseUrl = "BaseUrl"; + /// + /// The organization name property name. + /// + public const string OrgName = "OrgId"; + /// + /// The enabled flag property name. + /// + public const string Enabled = "Enabled"; + /// + /// The HTTP request timeout (seconds) property name. + /// + public const string TimeoutSeconds = "TimeoutSeconds"; + /// + /// The sync page size property name. + /// + public const string PageSize = "PageSize"; + /// + /// The force-complete-sync flag property name. + /// + public const string ForceCompleteSync = "ForceCompleteSync"; + /// + /// The pickup poll retry count property name. + /// + public const string PickupRetries = "PickupRetries"; + /// + /// The pickup poll delay (seconds) property name. + /// + public const string PickupDelaySeconds = "PickupDelaySeconds"; + } + + /// + /// Contains constant keys for enrollment configuration parameters. + /// + public static class EnrollmentConfigConstants + { + /// + /// The additional emails parameter name. + /// + public const string AdditionalEmails = "AdditionalEmails"; + /// + /// The MarkMonitor group parameter name. + /// + public const string MarkmonitorGroup = "MarkmonitorGroup"; + /// + /// The MarkMonitor contact parameter name. + /// + public const string MarkmonitorContact = "MarkmonitorContact"; + /// + /// The domain control validation method parameter name. + /// + public const string DCVMethod = "DCVMethod"; + /// + /// The order comments parameter name. + /// + public const string Comments = "comments"; + /// + /// The order locale parameter name. + /// + public const string Locale = "locale"; + /// + /// The certificate provider parameter name. + /// + public const string Provider = "provider"; + /// + /// The renewal window (days) parameter name. + /// + public const string RenewalWindowDays = "RenewalWindowDays"; + } +} \ No newline at end of file diff --git a/markmonitor-caplugin/MarkMonitorConfig.cs b/markmonitor-caplugin/MarkMonitorConfig.cs new file mode 100644 index 0000000..76d4cef --- /dev/null +++ b/markmonitor-caplugin/MarkMonitorConfig.cs @@ -0,0 +1,137 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Newtonsoft.Json; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor; + +/// +/// Represents the configuration settings required for the MarkMonitor CA Plugin. +/// +public class MarkMonitorConfig +{ + /// + /// The API key used to authenticate with the MarkMonitor API. + /// + [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.ApiKey)] + public string ApiKey { get; set; } + + /// + /// The password for the MarkMonitor API service account. + /// + [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.ApiPassword)] + public string ApiPassword { get; set; } + + /// + /// The username for the MarkMonitor API service account. + /// + [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.ApiUsername)] + public string ApiUsername { get; set; } + + /// + /// The base URL for the MarkMonitor API. + /// + [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.BaseUrl)] + public string BaseUrl { get; set; } + + /// + /// The organization name used in MarkMonitor operations. + /// + [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.OrgName)] + public string OrgName { get; set; } + + /// + /// Indicates whether the MarkMonitor CA Plugin is enabled. + /// + [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.Enabled)] + public bool Enabled { get; set; } + + private const int MinTimeoutSeconds = 1; + private const int MaxTimeoutSeconds = 120; + private int _timeoutSeconds = 120; + + /// + /// The HTTP request timeout, in seconds, for calls to the MarkMonitor API. Clamped to + /// [, ] in the setter - a value + /// <= 0 would otherwise crash HttpClient.Timeout's own setter with an unhandled + /// ArgumentOutOfRangeException (verified: .NET rejects non-positive timeouts), and the upper + /// bound is capped at this field's own pre-existing hardcoded default so a misconfigured value + /// can never make a slow-MarkMonitor scenario worse than before this field was configurable - + /// notably bounding how long AuthenticateAsync can hold the shared auth lock across its 3 retry + /// attempts. A property initializer (not just the annotation's DefaultValue) is required here so + /// an existing saved CA connection - created before this field existed, and so missing it + /// entirely from its stored JSON - still gets a sane timeout rather than 0. + /// + [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.TimeoutSeconds)] + public int TimeoutSeconds + { + get => _timeoutSeconds; + set => _timeoutSeconds = Math.Clamp(value, MinTimeoutSeconds, MaxTimeoutSeconds); + } + + private const int MinPageSize = 1; + private const int MaxPageSize = 500; + private int _pageSize = 100; + + /// + /// The number of certificate orders requested per page during synchronization. Clamped to + /// [, ] in the setter, so both a + /// JSON-deserialized value and a directly-assigned one are always sane - not to be confused + /// with MarkMonitorClient.NameResolutionPageSize, an unrelated fixed page size used only + /// for org/group name lookups. + /// + [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.PageSize)] + public int PageSize + { + get => _pageSize; + set => _pageSize = Math.Clamp(value, MinPageSize, MaxPageSize); + } + + /// + /// When true, bypasses the skip-unchanged sync optimization and re-emits every order on every + /// synchronization, regardless of whether Command already has it at the same status. + /// + [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.ForceCompleteSync)] + public bool ForceCompleteSync { get; set; } + + private const int MaxPickupRetries = 20; + private int _pickupRetries = 5; + + /// + /// How many times to poll a freshly-created order for issuance before falling back to returning + /// it in its still-pending state. 0 disables polling entirely. Clamped to + /// [0, ] - combined with 's own + /// cap, this bounds Enroll's worst-case added latency (and, since polling runs before the + /// enrollment dedup reservation resolves, how long a concurrent duplicate call can block behind + /// it) to a fixed, sane ceiling rather than an operator-configurable unbounded one. + /// + [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.PickupRetries)] + public int PickupRetries + { + get => _pickupRetries; + set => _pickupRetries = Math.Clamp(value, 0, MaxPickupRetries); + } + + private const int MaxPickupDelaySeconds = 60; + private int _pickupDelaySeconds = 10; + + /// The delay between pickup polls, in seconds. Clamped to + /// [0, ]. + [JsonProperty(MarkMonitorCAPluginConfig.ConfigConstants.PickupDelaySeconds)] + public int PickupDelaySeconds + { + get => _pickupDelaySeconds; + set => _pickupDelaySeconds = Math.Clamp(value, 0, MaxPickupDelaySeconds); + } +} \ No newline at end of file diff --git a/markmonitor-cagateway/Models/Enums.cs b/markmonitor-caplugin/Models/Enums.cs similarity index 83% rename from markmonitor-cagateway/Models/Enums.cs rename to markmonitor-caplugin/Models/Enums.cs index 0028a51..e887c1e 100644 --- a/markmonitor-cagateway/Models/Enums.cs +++ b/markmonitor-caplugin/Models/Enums.cs @@ -1,4 +1,18 @@ -using System.ComponentModel; +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.ComponentModel; using System.Reflection; namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; @@ -133,11 +147,24 @@ public enum CertServerPlatforms [Description("MICROSOFT_IIS_5_OR_6")] MicrosoftIis5Or6 } +public enum DomainControlValidationMethods +{ + //EMAIL, DNS_CNAME_TOKEN, HTTP_TOKEN, DNS_TXT_TOKEN + [Description("EMAIL")] Email, + + [Description("DNS_CNAME_TOKEN")] DnsCNameToken, + + [Description("HTTP_TOKEN")] HttpToken, + + [Description("DNS_TXT_TOKEN")] DnsTxtToken +} + public static class EnumExtensions { public static string GetDescription(this Enum value) { var field = value.GetType().GetField(value.ToString()); + if (field == null) return ""; var attribute = field.GetCustomAttribute(); return attribute?.Description ?? value.ToString(); } diff --git a/markmonitor-cagateway/Models/MarkMonitorCommon.cs b/markmonitor-caplugin/Models/MarkMonitorCommon.cs similarity index 79% rename from markmonitor-cagateway/Models/MarkMonitorCommon.cs rename to markmonitor-caplugin/Models/MarkMonitorCommon.cs index e3c2962..82759b4 100644 --- a/markmonitor-cagateway/Models/MarkMonitorCommon.cs +++ b/markmonitor-caplugin/Models/MarkMonitorCommon.cs @@ -1,3 +1,17 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using Newtonsoft.Json; namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; diff --git a/markmonitor-cagateway/Models/MarkMonitorGetContactResponse.cs b/markmonitor-caplugin/Models/MarkMonitorGetContactResponse.cs similarity index 85% rename from markmonitor-cagateway/Models/MarkMonitorGetContactResponse.cs rename to markmonitor-caplugin/Models/MarkMonitorGetContactResponse.cs index fdf0af0..8f5e7dd 100644 --- a/markmonitor-cagateway/Models/MarkMonitorGetContactResponse.cs +++ b/markmonitor-caplugin/Models/MarkMonitorGetContactResponse.cs @@ -1,3 +1,17 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using Newtonsoft.Json; namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; diff --git a/markmonitor-cagateway/Models/MarkMonitorGetOrganizationResponse.cs b/markmonitor-caplugin/Models/MarkMonitorGetOrganizationResponse.cs similarity index 88% rename from markmonitor-cagateway/Models/MarkMonitorGetOrganizationResponse.cs rename to markmonitor-caplugin/Models/MarkMonitorGetOrganizationResponse.cs index d6ffae7..d616065 100644 --- a/markmonitor-cagateway/Models/MarkMonitorGetOrganizationResponse.cs +++ b/markmonitor-caplugin/Models/MarkMonitorGetOrganizationResponse.cs @@ -1,3 +1,17 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using Newtonsoft.Json; namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; diff --git a/markmonitor-caplugin/Models/MarkMonitorListCertificatesResponse.cs b/markmonitor-caplugin/Models/MarkMonitorListCertificatesResponse.cs new file mode 100644 index 0000000..532fbbb --- /dev/null +++ b/markmonitor-caplugin/Models/MarkMonitorListCertificatesResponse.cs @@ -0,0 +1,35 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Newtonsoft.Json; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; + +/// +/// Represents the response for a list of orders. +/// +public class MarkMonitorListOrdersResponse +{ + /// + /// Gets or sets the content of the response. + /// + [JsonProperty("content")] + public List Content { get; set; } + + /// + /// Gets or sets the pagination information. + /// + [JsonProperty("page")] + public MarkMonitorPageInfo MarkMonitorPage { get; set; } +} \ No newline at end of file diff --git a/markmonitor-caplugin/Models/MarkMonitorListContactsResponse.cs b/markmonitor-caplugin/Models/MarkMonitorListContactsResponse.cs new file mode 100644 index 0000000..c4c5e89 --- /dev/null +++ b/markmonitor-caplugin/Models/MarkMonitorListContactsResponse.cs @@ -0,0 +1,35 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Newtonsoft.Json; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; + +/// +/// Represents the response for listing contacts. +/// +public class MarkMonitorListContactsResponse +{ + /// + /// Gets or sets the content of the response. + /// + [JsonProperty("content")] + public List Content { get; set; } + + /// + /// Gets or sets the pagination information. + /// + [JsonProperty("page")] + public MarkMonitorPageInfo Page { get; set; } +} \ No newline at end of file diff --git a/markmonitor-caplugin/Models/MarkMonitorListGroupsResponse.cs b/markmonitor-caplugin/Models/MarkMonitorListGroupsResponse.cs new file mode 100644 index 0000000..af86e73 --- /dev/null +++ b/markmonitor-caplugin/Models/MarkMonitorListGroupsResponse.cs @@ -0,0 +1,71 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Newtonsoft.Json; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; + +/// +/// Represents the response for listing groups from the MarkMonitor Auth API. +/// +public class MarkMonitorListGroupsResponse +{ + /// + /// Gets or sets the groups returned. + /// + [JsonProperty("groups")] + public List Groups { get; set; } + + /// + /// Gets or sets the pagination information. + /// + [JsonProperty("page")] + public MarkMonitorPageInfo MarkMonitorPage { get; set; } +} + +/// +/// Represents a MarkMonitor group that an order can be associated with. +/// +public class MarkMonitorGroup +{ + /// + /// Gets or sets the date the group was created. + /// + [JsonProperty("dateCreated")] + public DateTime DateCreated { get; set; } + + /// + /// Gets or sets the date the group was last updated. + /// + [JsonProperty("dateUpdated")] + public DateTime DateUpdated { get; set; } + + /// + /// Gets or sets the name of the group. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// Gets or sets the description of the group. + /// + [JsonProperty("description")] + public string Description { get; set; } + + /// + /// Gets or sets the ID of the group. + /// + [JsonProperty("id")] + public string Id { get; set; } +} diff --git a/markmonitor-caplugin/Models/MarkMonitorListOrgsResponse.cs b/markmonitor-caplugin/Models/MarkMonitorListOrgsResponse.cs new file mode 100644 index 0000000..77403da --- /dev/null +++ b/markmonitor-caplugin/Models/MarkMonitorListOrgsResponse.cs @@ -0,0 +1,35 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Newtonsoft.Json; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; + +/// +/// Represents the response for listing organizations. +/// +public class MarkMonitorListOrgsResponse +{ + /// + /// Gets or sets the content of the response. + /// + [JsonProperty("content")] + public List Content { get; set; } + + /// + /// Gets or sets the pagination information. + /// + [JsonProperty("page")] + public MarkMonitorPageInfo MarkMonitorPage { get; set; } +} \ No newline at end of file diff --git a/markmonitor-cagateway/Models/MarkMonitorOrder.cs b/markmonitor-caplugin/Models/MarkMonitorOrder.cs similarity index 84% rename from markmonitor-cagateway/Models/MarkMonitorOrder.cs rename to markmonitor-caplugin/Models/MarkMonitorOrder.cs index 68d5a8e..8afff24 100644 --- a/markmonitor-cagateway/Models/MarkMonitorOrder.cs +++ b/markmonitor-caplugin/Models/MarkMonitorOrder.cs @@ -1,3 +1,17 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using Newtonsoft.Json; namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; @@ -67,6 +81,12 @@ public class OrderContent [JsonProperty("groupId")] public string GroupId { get; set; } + /// + /// Gets or sets the organization ID that owns this order. + /// + [JsonProperty("organizationId")] + public string OrganizationId { get; set; } + /// /// Gets or sets the contacts associated with the order. /// diff --git a/markmonitor-cagateway/Models/MarkMonitorOrderRequest.cs b/markmonitor-caplugin/Models/MarkMonitorOrderRequest.cs similarity index 89% rename from markmonitor-cagateway/Models/MarkMonitorOrderRequest.cs rename to markmonitor-caplugin/Models/MarkMonitorOrderRequest.cs index feecdf5..a9a2492 100644 --- a/markmonitor-cagateway/Models/MarkMonitorOrderRequest.cs +++ b/markmonitor-caplugin/Models/MarkMonitorOrderRequest.cs @@ -1,3 +1,17 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using Newtonsoft.Json; using Org.BouncyCastle.Crypto; diff --git a/markmonitor-cagateway/Models/MarkMonitorReissueRequest.cs b/markmonitor-caplugin/Models/MarkMonitorReissueRequest.cs similarity index 57% rename from markmonitor-cagateway/Models/MarkMonitorReissueRequest.cs rename to markmonitor-caplugin/Models/MarkMonitorReissueRequest.cs index 7b8c039..5831c57 100644 --- a/markmonitor-cagateway/Models/MarkMonitorReissueRequest.cs +++ b/markmonitor-caplugin/Models/MarkMonitorReissueRequest.cs @@ -1,3 +1,17 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using Newtonsoft.Json; namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; diff --git a/markmonitor-caplugin/Models/TokenRequest.cs b/markmonitor-caplugin/Models/TokenRequest.cs new file mode 100644 index 0000000..cf7fc48 --- /dev/null +++ b/markmonitor-caplugin/Models/TokenRequest.cs @@ -0,0 +1,24 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Newtonsoft.Json; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; + +public class TokenRequest +{ + [JsonProperty("username")] public string Username { get; set; } + + [JsonProperty("password")] public string Password { get; set; } +} \ No newline at end of file diff --git a/markmonitor-caplugin/Models/TokenResponse.cs b/markmonitor-caplugin/Models/TokenResponse.cs new file mode 100644 index 0000000..9f69671 --- /dev/null +++ b/markmonitor-caplugin/Models/TokenResponse.cs @@ -0,0 +1,24 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Newtonsoft.Json; + +namespace Keyfactor.Extensions.CAPlugin.MarkMonitor.Models; + +public class TokenResponse +{ + [JsonProperty("token")] public string BearerToken { get; set; } + + [JsonProperty("expiresIn")] public int ExpiresIn { get; set; } +} \ No newline at end of file diff --git a/markmonitor-cagateway/Properties/AssemblyInfo.cs b/markmonitor-caplugin/Properties/AssemblyInfo.cs similarity index 57% rename from markmonitor-cagateway/Properties/AssemblyInfo.cs rename to markmonitor-caplugin/Properties/AssemblyInfo.cs index 176f3e7..260d3e2 100644 --- a/markmonitor-cagateway/Properties/AssemblyInfo.cs +++ b/markmonitor-caplugin/Properties/AssemblyInfo.cs @@ -1,15 +1,32 @@ -using System.Reflection; +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +[assembly: InternalsVisibleTo("markmonitor-caplugin.Tests")] + // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information // associated with an assembly. -[assembly: AssemblyTitle("markmonitor-cagateway")] +[assembly: AssemblyTitle("markmonitor-caplugin")] [assembly: AssemblyDescription("")] [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("markmonitor-cagateway")] -[assembly: AssemblyCopyright("Copyright © 2022")] +[assembly: AssemblyProduct("markmonitor-caplugin")] +[assembly: AssemblyCopyright("Copyright © 2026")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] diff --git a/markmonitor-cagateway/app.config b/markmonitor-caplugin/app.config similarity index 100% rename from markmonitor-cagateway/app.config rename to markmonitor-caplugin/app.config diff --git a/markmonitor-cagateway/manifest.json b/markmonitor-caplugin/manifest.json similarity index 88% rename from markmonitor-cagateway/manifest.json rename to markmonitor-caplugin/manifest.json index 3d2dba6..bf0d33b 100644 --- a/markmonitor-cagateway/manifest.json +++ b/markmonitor-caplugin/manifest.json @@ -1,7 +1,7 @@ { "extensions": { "Keyfactor.AnyGateway.Extensions.IAnyCAPlugin": { - "GCPCASCAPlugin": { + "MarkMonitorCAPlugin": { "assemblypath": "MarkMonitorCAPlugin.dll", "TypeFullName": "Keyfactor.Extensions.CAPlugin.MarkMonitor.MarkMonitorCAPlugin" } diff --git a/markmonitor-caplugin/markmonitor-caplugin.csproj b/markmonitor-caplugin/markmonitor-caplugin.csproj new file mode 100644 index 0000000..49ccc41 --- /dev/null +++ b/markmonitor-caplugin/markmonitor-caplugin.csproj @@ -0,0 +1,47 @@ + + + + net8.0;net10.0 + Keyfactor.Extensions.CAPlugin.MarkMonitor + enable + disable + MarkMonitorCAPlugin + true + + + false + Keyfactor Inc + MarkMonitor CA Gateway + 1.0.0.0 + 1.0.0.0 + + + + + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/scripts/lib/gateway-auth.sh b/scripts/lib/gateway-auth.sh new file mode 100755 index 0000000..a48a6fd --- /dev/null +++ b/scripts/lib/gateway-auth.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Shared OAuth2 + REST helpers for talking to the AnyCA REST Gateway's admin API +# (config/certificateprofile, etc.) - not the MarkMonitor vendor API (see the +# root justfile for that). +# +# Usage: +# . "$(dirname "$0")/lib/gateway-auth.sh" +# tok=$(gateway_token) +# gw_curl "$tok" GET /config/certificateprofile +# +# Required env (export before sourcing, or set in a root .env - see justfile): +# GATEWAY_HOST gateway ingress host (no scheme) +# Auth - one of: +# GATEWAY_COOKIE a pasted browser session cookie (Portal UI auth) +# GATEWAY_TOKEN a pre-obtained bearer token +# TOKEN_URL + OIDC_CLIENT_ID + OIDC_CLIENT_SECRET OAuth2 client_credentials +# Optional env (defaults shown): +# GATEWAY_SCHEME https +# GATEWAY_BASE_PATH /AnyGatewayREST (the gateway *instance* mount path - +# on a multi-instance gateway this is instance-specific, +# e.g. /markmonitor-0 - check the Portal/Swagger URL) +# GATEWAY_SCOPE keyfactor-anyca-gateway +# CURL_INSECURE 1 (pass -k; set 0 to verify TLS) + +GATEWAY_SCHEME="${GATEWAY_SCHEME:-https}" +GATEWAY_BASE_PATH="${GATEWAY_BASE_PATH:-/AnyGatewayREST}" +GATEWAY_SCOPE="${GATEWAY_SCOPE:-keyfactor-anyca-gateway}" +CURL_INSECURE="${CURL_INSECURE:-1}" + +_gw_require() { + local missing=0 v + for v in "$@"; do + if [ -z "${!v:-}" ]; then + echo "ERROR: required env var '$v' is not set" >&2 + missing=1 + fi + done + [ "$missing" -eq 0 ] || return 1 +} + +# Base curl flags shared by every call (bash 3.2 compatible - global array). +GW_CURL_OPTS=(-sS) +[ "$CURL_INSECURE" = "1" ] && GW_CURL_OPTS+=(-k) + +# oauth_token [scope] - fetch a client_credentials bearer token. +# Echoes the raw access_token. Exits non-zero (and prints the body) on failure. +oauth_token() { + _gw_require TOKEN_URL OIDC_CLIENT_ID OIDC_CLIENT_SECRET || return 1 + local scope="${1:-}" + local -a form=( + --data-urlencode "grant_type=client_credentials" + --data-urlencode "client_id=${OIDC_CLIENT_ID}" + --data-urlencode "client_secret=${OIDC_CLIENT_SECRET}" + ) + [ -n "$scope" ] && form+=(--data-urlencode "scope=${scope}") + + local resp tok + resp=$(curl "${GW_CURL_OPTS[@]}" -X POST "$TOKEN_URL" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + "${form[@]}") || { echo "ERROR: token request failed" >&2; return 1; } + tok=$(printf '%s' "$resp" | jq -r '.access_token // empty') + if [ -z "$tok" ]; then + echo "ERROR: no access_token in response:" >&2 + printf '%s\n' "$resp" >&2 + return 1 + fi + printf '%s' "$tok" +} + +# Auth resolution order: +# 1. GATEWAY_COOKIE - a pasted browser session cookie (gw_curl sends it +# directly; this function returns empty and callers skip Authorization). +# 2. GATEWAY_TOKEN - an explicit pre-obtained bearer token. +# 3. OAuth2 client_credentials via oauth_token. +gateway_token() { + if [ -n "${GATEWAY_COOKIE:-}" ]; then return 0; fi + if [ -n "${GATEWAY_TOKEN:-}" ]; then printf '%s' "$GATEWAY_TOKEN"; return 0; fi + oauth_token "$GATEWAY_SCOPE" +} + +gw_base() { + _gw_require GATEWAY_HOST || return 1 + printf '%s://%s%s' "$GATEWAY_SCHEME" "$GATEWAY_HOST" "$GATEWAY_BASE_PATH" +} + +gw_show() { if [ -n "${GATEWAY_HOST:-}" ]; then gw_base; else printf '(GATEWAY_HOST unset)'; fi; } + +# gw_curl [data] - hits the gateway admin API. +# is relative to GATEWAY_BASE_PATH (e.g. /config/certificateprofile). Echoes +# the response body. +gw_curl() { + local tok="$1" method="$2" path="$3" data="${4:-}" + local rw="APIClient" + [ -n "${GATEWAY_COOKIE:-}" ] && rw="XMLHttpRequest" + local -a args=("${GW_CURL_OPTS[@]}" -X "$method" "$(gw_base)$path" + -H "x-keyfactor-requested-with: $rw" + -H "Content-Type: application/json") + if [ -n "${GATEWAY_COOKIE:-}" ]; then + args+=(-H "Cookie: ${GATEWAY_COOKIE}" -H "x-requested-with: XMLHttpRequest") + fi + [ -n "$tok" ] && args+=(-H "Authorization: Bearer $tok") + [ -n "$data" ] && args+=(-d "$data") + curl "${args[@]}" +} + +# manifest_product_ids [manifest-path] - emit product_ids one per line. +manifest_product_ids() { + local manifest="${1:-$REPO_ROOT/integration-manifest.json}" + jq -r '.about.carest.product_ids[]' "$manifest" +} diff --git a/scripts/register-gateway-profiles.sh b/scripts/register-gateway-profiles.sh new file mode 100755 index 0000000..55d4201 --- /dev/null +++ b/scripts/register-gateway-profiles.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Register AnyCA REST Gateway certificate profiles for this plugin. +# +# Creates (or updates) one gateway certificate profile per MarkMonitor product, +# driven by .about.carest.product_ids in integration-manifest.json. Idempotent: +# existing profiles (matched by name) are PUT-updated, new ones are POSTed. +# +# This only touches gateway certificate profiles (/config/certificateprofile). +# It does not register a CA connection or import Command templates - do that +# by hand (or with equivalent scripting) once profiles exist. +# +# Env: see scripts/lib/gateway-auth.sh for the auth/host contract. +# Optional: +# KEY_ALGS_JSON override the key_algs object (default: RSA + P-256/P-384/P-521 below) +# MANIFEST path to integration-manifest.json (default: repo root) +# CHECK 1 = after applying, list the resulting profile names +# DRY_RUN 1 = print intended actions, make no write calls +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +export REPO_ROOT + +# shellcheck disable=SC1090 +[ -f "$REPO_ROOT/.env" ] && . "$REPO_ROOT/.env" +# shellcheck source=lib/gateway-auth.sh +. "$SCRIPT_DIR/lib/gateway-auth.sh" + +MANIFEST="${MANIFEST:-$REPO_ROOT/integration-manifest.json}" +DRY_RUN="${DRY_RUN:-0}" +CHECK="${CHECK:-0}" + +# MarkMonitor orders are enrolled with RSA or named-curve ECC CSRs (see +# MarkMonitorClient.ValidateEccCsrUsesNamedCurve) - P-256/P-384/P-521 cover +# what TestConsole/CSRGenerator exercises. +DEFAULT_KEY_ALGS_JSON='{ + "rsa": { "bit_lengths": [2048, 3072, 4096] }, + "ecdsa": { "curves": ["1.2.840.10045.3.1.7", "1.3.132.0.34", "1.3.132.0.35"] } +}' +KEY_ALGS_JSON="${KEY_ALGS_JSON:-$DEFAULT_KEY_ALGS_JSON}" + +if ! echo "$KEY_ALGS_JSON" | jq -e . >/dev/null 2>&1; then + echo "ERROR: KEY_ALGS_JSON is not valid JSON" >&2 + exit 1 +fi + +echo "== MarkMonitor gateway certificate profiles ==" +echo " gateway : $(gw_show)" +echo " manifest: $MANIFEST" +[ "$DRY_RUN" = "1" ] && echo " DRY_RUN : no write calls will be made" + +PRODUCTS=() +while IFS= read -r _p; do + [ -n "$_p" ] && PRODUCTS+=("$_p") +done < <(manifest_product_ids "$MANIFEST") +[ "${#PRODUCTS[@]}" -gt 0 ] || { echo "ERROR: no product_ids in manifest" >&2; exit 1; } +echo " products: ${#PRODUCTS[@]}" + +if [ "$DRY_RUN" = "1" ]; then + # Fully offline preview: no token, no listing. + echo " (dry run) would upsert ${#PRODUCTS[@]} profiles with key_algs:" + echo "$KEY_ALGS_JSON" | jq -c . + for name in "${PRODUCTS[@]}"; do + printf ' [DRY ] %s\n' "$name" + done + echo "== done (dry run): no calls made ==" + exit 0 +fi + +TOK="$(gateway_token)" + +# Snapshot existing profiles once: name -> id. +EXISTING="$(gw_curl "$TOK" GET /config/certificateprofile)" +if ! echo "$EXISTING" | jq -e 'type == "array"' >/dev/null 2>&1; then + echo "ERROR: unexpected response listing certificate profiles:" >&2 + printf '%s\n' "$EXISTING" >&2 + exit 1 +fi + +created=0 updated=0 +for name in "${PRODUCTS[@]}"; do + existing_id="$(echo "$EXISTING" | jq -r --arg n "$name" \ + '.[] | select(.name == $n) | .id' | head -n1)" + + body="$(jq -n --arg name "$name" --argjson algs "$KEY_ALGS_JSON" \ + '{name: $name, key_algs: $algs}')" + + if [ -n "$existing_id" ] && [ "$existing_id" != "null" ]; then + body="$(echo "$body" | jq --argjson id "$existing_id" '. + {id: $id}')" + printf ' [PUT ] %-40s (id=%s)\n' "$name" "$existing_id" + resp="$(gw_curl "$TOK" PUT /config/certificateprofile "$body")" + echo "$resp" | jq -e 'has("error") or has("Message")' >/dev/null 2>&1 \ + && { echo " ! update failed: $resp" >&2; } + updated=$((updated + 1)) + else + printf ' [POST] %-40s (new)\n' "$name" + resp="$(gw_curl "$TOK" POST /config/certificateprofile "$body")" + echo "$resp" | jq -e 'has("error") or has("Message")' >/dev/null 2>&1 \ + && { echo " ! create failed: $resp" >&2; } + created=$((created + 1)) + fi +done + +echo "== done: $created created, $updated updated ==" + +if [ "$CHECK" = "1" ]; then + echo "== CHECK: profiles now on the gateway ==" + gw_curl "$TOK" GET /config/certificateprofile | jq -r '.[].name' | sort +fi