# Hortval Documentation Full Export Source: https://docs.hortval.com/ --- --- # administration/antivirus-edr.md --- sidebar_position: 8 title: Antivirus & EDR --- # Antivirus & EDR :::tip Native connector (default): no `certreq.exe` spawn Since **v0.9.2** the default ADCS connector (`type: adcs` / `adcs-native`) enrolls **in-process** — Hortval launches no child process. The LOLBin parent-child signature that strict EDRs used to flag (the same pattern as offensive ADCS tooling such as Certify / Certipy) is simply not produced. For a default deployment, the certreq-specific guidance on this page **does not apply** — the remaining host activity (an HTTPS listener, a local database, an append-only log) is benign. The certreq.exe details below apply **only if you explicitly chose the `adcs-cli` connector** (see [Authorities](../configuration/authorities.md)). ::: With the **native connector (default)**, Hortval on a Windows ADCS host binds an HTTPS listener, writes to a local database, and appends to an audit log — no child process, no transient certificate files on disk. With the **`adcs-cli` connector**, Hortval additionally spawns `certreq.exe` and writes transient CSR / certificate files for each issuance. Endpoint Detection and Response (EDR) products (Microsoft Defender for Endpoint, CrowdStrike Falcon, SentinelOne, ESET, Sophos, …) sometimes flag that parent-child chain, because the same primitives appear in offensive playbooks. This page lists what Hortval does on the host, what to allow-list before deploying, and how to react if the EDR blocks something unexpectedly. It is a best-effort baseline — Hortval is not certified against any specific EDR product, and your security team owns the final policy. ## What Hortval does on the host | Activity | When | Connector | Why an EDR may flag it | |---|---|---|---| | Binds `0.0.0.0:443` (or configured port) | Boot | both | A non-IIS process binding `:443` on a Windows server is unusual | | Writes to the SQLite database file in `` | Continuous | both | Large write rate to an opaque file format | | Appends to `/audit.log` | Every protocol event | both | Log file growth is usually benign | | Enrolls in-process via the Windows certificate API | Every order finalize / status poll | native (default) | No child process; indistinguishable from a normal enrollment client | | Spawns `certreq.exe -submit`, `-retrieve`, `-config` | Every order finalize, every status poll | `adcs-cli` only | New parent → `certreq.exe` chains are uncommon outside auto-enrolment, EDRs often score them | | Writes `*.csr`, `*.cer`, `*.req` to `/adcs/` | Transient, deleted within seconds of each issuance | `adcs-cli` only | File-creation+deletion bursts of certificate-looking content | ## Recommended exclusions Add the following to your EDR/AV real-time scanning exclusions **before** starting Hortval. Replace `C:\Program Files\Hortval\` and the workdir path with your actual install path. ### Process exclusions - `C:\Program Files\Hortval\hortval.exe` — the Hortval binary itself. - `C:\Windows\System32\certreq.exe` — **`adcs-cli` connector only.** Invoked by Hortval in that mode. Usually already trusted by Defender, but third-party EDRs may not whitelist it by default in non-standard parent-child relationships. The default native connector launches no child process, so this exclusion is unnecessary there. ### Path exclusions - `\` — the entire Hortval work directory. Subpaths to focus on if blanket exclusion is not acceptable: - `\adcs\` — **`adcs-cli` connector only:** transient CSR / certificate scratch space (high file-creation rate). The native connector writes no such files. - `\db.sqlite`, `\db.sqlite-wal`, `\db.sqlite-shm` — SQLite database files (frequent writes). - `\audit.log` — append-only audit log. - `\server-certificate-cache\` — TLS certificate bundles for the ACME endpoint. ### Network exclusions If your EDR has an outbound-connection monitor, allow: - The ACME listening port (default `:443` or whatever you configured under `server.port`). - Traffic to the ADCS CA host (typically port `135` for RPC + dynamic high ports for the actual call — the same RPC/DCOM ports any Windows enrollment client uses, whether native or `certreq`). - Traffic to your DNS resolver(s) configured under `dns-validation-profiles`. ## Windows SmartScreen / Application Control **From v0.9.5 the Windows binary is signed** (Authenticode, timestamped). See [Verifying release binaries](../security/verifying-binaries.md) for the check and the publisher name to expect. ### When SmartScreen actually prompts Not "when a binary is unsigned" — that is the common misreading. The app dialog is raised by the **Mark of the Web**: an NTFS alternate data stream a browser or mail client attaches to what it saves. **No mark, no prompt**, signed or not. That distinction decides what your operators will see: | How the binary reached the machine | Marked? | Prompt | |---|---|---| | Downloaded with a browser | yes | possible | | `curl.exe`, `Invoke-WebRequest` | no | no | | Copied from an internal share, or a USB stick | no | no | | Deployed by your software distribution tool | no | no | So an administrator who fetches Hortval from a terminal, or deploys it from an internal repository — which is how it usually arrives on a server — will not meet SmartScreen at all. **Which is a reason to verify the signature, not a reason to skip it.** Those are the paths on which nothing is checked and nothing is displayed, and this binary ends up on a host that enrols certificates against your CA. See [Verifying release binaries](../security/verifying-binaries.md). An administrator can also clear the mark deliberately: *Properties → Unblock* on the file, or `Unblock-File` in PowerShell. ### What signing changes, and what it does not It does **not** remove the prompt. SmartScreen weighs two things: the file's own download history, and the publisher's reputation. An unsigned binary accrues reputation per exact file, so every release starts from zero. A signed one accrues it on the **publisher identity**, so releases build on each other. What changes immediately is the name: the dialog reads the publisher instead of *"Unknown publisher"* — which is the part you can verify. A recently published release may still warn while that reputation is young. Note that the name is **not on the first screen**. That one offers a single `Don't run` button; the publisher appears only after clicking **More info** — and on a server carrying the Microsoft security baseline (*"Warn and prevent bypass"*), there is no override button at all. Clear the Mark of the Web with `Unblock-File`, or allow the binary with an AppLocker publisher rule. The exact sequence is in [Verifying release binaries](../security/verifying-binaries.md#the-publisher-name-is-behind-a-click). ### AppLocker / WDAC A signed binary lets you write a **publisher rule** rather than a path rule, which survives a move or a rename and does not have to be widened to a directory. A path rule pointing at your install directory remains valid, and is the simpler option if your policy already works that way. ## If your EDR blocks Hortval Symptoms to look for: - Hortval exits immediately at startup with `access denied` errors on its workdir (or, with `adcs-cli`, on `certreq.exe`). - ACME orders fail at finalize with a backend error; the audit log shows repeated `certificate.issue` failures with the same reason. With `adcs-cli` the error typically mentions `certreq` not found or terminated. - (`adcs-cli` only) A long latency on every order, because the EDR intercepts and analyses each `certreq.exe` spawn before letting it run. To diagnose: 1. Pull the EDR's quarantine / detection log for the host and filter on `hortval.exe` and `certreq.exe`. The detection name and the rule ID tell your security team which heuristic fired. 2. Add the [recommended exclusions](#recommended-exclusions) and restart Hortval. 3. If detections continue, capture a Hortval stderr trace (`APP_LOG_LEVEL=debug`) covering one failed order and share it with your EDR vendor along with the rule ID — that is enough for them to issue an exception or a tuned signature. ## Linux Linux deployments of Hortval do not invoke `certreq.exe` — the equivalent activity is local-only (SQLite + audit log + ACME network traffic). If your Linux host runs an EDR agent, the recommended exclusions reduce to the workdir and the listening port; the process exclusion is rarely needed because Linux EDRs do not generally weight `:443` binders the same way. ## What is NOT a sign of EDR interference These behaviours are normal and should not be reported to your security team as a Hortval issue: - Brief CPU bursts on the host during a batch of finalize calls — enrollment does cryptographic work (and, with `adcs-cli`, each `certreq.exe` spawn). - (`adcs-cli` only) A new `\adcs\` file appearing and disappearing within a second during issuance — the file is the live CSR, deleted as soon as the ADCS response is parsed. - An audit-log line per protocol event — the audit log is append-only by design and meant to grow. --- # administration/audit.md --- sidebar_position: 6 title: Audit Log --- # Audit Log Hortval writes a tamper-evident audit log of business decisions and security-relevant events: account lifecycle, order creation, authorization outcomes, certificate issuance and revocation, rate-limit denials, and license transitions. Each line is HMAC-chained to the previous one so any insertion, deletion, or modification is detectable after the fact with the bundled `hortval audit verify` command. The audit log is **enabled by default**: a silent opt-out would be a compliance hole. Disable it explicitly with `enabled: false` if you have a specific reason. ## Configuration ```yaml audit: enabled: true path: "" rotate: max-size-mb: 0 ``` Omitting the `audit` block applies the defaults shown above. | Field | Default | Meaning | |---|---|---| | `enabled` | `true` | Set to `false` to disable the audit writer entirely. No file is created and `Audit()` calls are dropped silently. | | `path` | `""` | Base path for the audit log. Empty resolves to `/audit.log`. With rotation enabled this is a **naming base**, not a file — see below. | | `rotate.max-size-mb` | `0` | When greater than zero, Hortval starts a new segment once the current one exceeds this size. `0` means a single segment that grows indefinitely. | :::danger Removed in v0.9.4 — `rotate.max-backups` This field no longer exists, and **a configuration that still contains it is refused at startup**. Remove it before upgrading. The audit log is a compliance artifact, not a log: its value lies in being complete. Deleting it by file count is not a setting, so the option was removed rather than validated against. Retention will return expressed as a **duration**, which is the unit a compliance requirement is actually written in. ::: ### Rotation is internal, and external rotation is not supported :::danger Do not point logrotate at the audit file Earlier versions of this page recommended a `logrotate` snippet with `copytruncate`. **That recommendation was wrong and is withdrawn.** A third party renaming or truncating the file breaks the HMAC chain, and Hortval cannot distinguish that from tampering. Remove any `logrotate` rule, Scheduled Task or backup job that rotates, truncates or moves `audit.path`. Copying the closed segments elsewhere is fine — see below. ::: Hortval segments the file itself. Each segment is named after the instant it was opened and is **never renamed**: ``` audit.path: C:\ProgramData\hortval\audit\audit.log -> C:\ProgramData\hortval\audit\audit.20260726T091702Z.ms123.log ``` Two consequences worth knowing: - **A closed segment is immutable.** It is never reopened for writing, so an archiver can copy or move closed segments with no risk of catching a file mid-rotation. Leave the newest one alone: it is the one being written. - **Hortval never deletes an audit segment.** Disk growth is bounded by your archival policy, not by the product. Sizing: at `max-size-mb: 100`, a busy enterprise CA produces on the order of a few segments per year. If a rotation cannot complete — an antivirus holding the file, a full disk, a permissions problem — Hortval keeps writing to the current segment and reports the reason on stderr (captured by systemd and the Windows service manager). It retries later. Nothing is lost and no history is touched. ## What Is Logged The audit log captures decisions and security events. The list is intentionally narrow: nonce churn, individual JWS verifications, GET requests on the directory, and `/metrics` scrapes are explicitly excluded — they would drown the signal in noise without forensic value. The tables below list every recorded event, what triggers it, and what extra context is attached in the `details` field. Common fields (`ts`, `account_id`, `source_ip`, `user_agent`) are described in [Line Format](#line-format) and are not repeated here. ### Account events | Event | When | Details | |---|---|---| | `account.create` | A new ACME account is created. The dedupe path (existing key reused) is silent. | `contact`: list of contact URIs declared by the client | | `account.keychange` | An account's JWK is rotated successfully via `keyChange`. | `old_thumbprint`, `new_thumbprint` | | `account.deactivate` | An account is set to `deactivated` by its owner. | _(none)_ | ### Order and authorization events | Event | When | Details | |---|---|---| | `order.create` | A new order is accepted and persisted. | `order_id`, `dns_names` (canonical: lowercased, sorted, deduped, wildcards preserved) | | `order.finalize` | A `finalize` request has been accepted and the issuance job is enqueued. Represents the client's accepted finalize, not the actual cert issuance. | `order_id`, `policy_id`, `dns_names` | | `order.invalid` | An order transitions to `invalid`. Emitted at most once per order — retries do not duplicate the event. The free-form `reason` field on the line carries the failure message. | `order_id`, `source`: `"challenge"` or `"pki"` | | `authorization.validate` | An authorization transitions to `valid` (`decision: allow`) or `invalid` (`decision: deny`). Deny is emitted only when the authorization actually flips to invalid (i.e. all challenges of that authz failed). | `authz_id`, `identifier`, `chall_type` | | `challenge.validate` | A challenge succeeds. **Failures are not recorded per challenge** — they would be too noisy. The chain "these challenges passed → this cert was issued" remains reconstructible from the order, authorization, and certificate events. | `chall_id`, `chall_type`, `identifier`, `authz_id` | ### Certificate events | Event | When | Details | |---|---|---| | `certificate.issue` | A certificate has been issued and persisted. | `cert_id`, `order_id`, `pki_request_id`, `serial`, `dns_names` | | `certificate.revoke` | A certificate is revoked via the `revoke-cert` endpoint. | `cert_id`, `fingerprint`, `reason` (RFC 5280 reason code; JSON `null` when the client did not provide one) | ### Rate limiting | Event | When | Details | |---|---|---| | `ratelimit.deny` | A request is refused with HTTP 429. Emitted once per refusal. | `type`: one of `global`, `account-creation`, `order-creation`, `duplicate-certificate`, `failed-validation`, `pending-authorizations`; `retry_after` (seconds) | ### License | Event | When | Details | |---|---|---| | `license.change` | The license state transitions between `valid`, `grace`, `expired`, `revoked`, or `no_license`. The boot baseline is not emitted — only subsequent transitions are. | `prev`, `next` | ## Line Format The log is JSONL — one self-contained JSON object per line. Field order is fixed by the schema version (currently `"1"`). ```json {"ts":"2026-05-08T14:32:11.123Z","schema":"1","seq":12345,"prev_mac":"hmac-sha256:abcd...","event":"order.create","account_id":"acct_abc","source_ip":"10.0.0.5","user_agent":"certbot/2.9","decision":"allow","details":{"order_id":"ord_xyz"},"mac":"hmac-sha256:1234..."} ``` | Field | Always present | Description | |---|---|---| | `ts` | yes | RFC 3339 timestamp with millisecond precision, in UTC | | `schema` | yes | Schema version string. Currently `"1"`. Bumped only on incompatible layout changes. | | `seq` | yes | Strictly increasing per installation. Resumes across restarts and rotations. | | `prev_mac` | yes | The `mac` of the previous line, prefixed with `hmac-sha256:`. The first line uses the **genesis MAC** anchored on the installation key. | | `event` | yes | Dot-namespaced event name (`order.create`, `certificate.revoke`, …) | | `account_id` | optional | ACME account identifier when the event is account-scoped | | `source_ip` | optional | Client IP (see RGPD note below) | | `user_agent` | optional | Client User-Agent string | | `decision` | optional | `allow` or `deny` for events that involve a policy check | | `reason` | optional | Human-readable reason — empty when not applicable | | `details` | optional | Event-specific payload, encoded as a JSON object | | `mac` | yes | `hmac-sha256:` of all preceding fields. Always the last field on the line. | Optional fields are omitted from the line when empty (`omitempty`). ## How the Chain Works On the very first install, Hortval generates a 32-byte random secret and stores it in the database (`audit_state` table). The secret is never logged, never exposed via any API, and never rotated. The first line of every installation is anchored to a **genesis MAC** computed from the secret and the stable installation identifier: ``` genesis_mac = HMAC-SHA-256(secret, "certeasy-audit-v1|" + installation_key) ``` Each subsequent line carries the `mac` of the previous line in its `prev_mac` field, and computes its own `mac` as `HMAC-SHA-256(secret, line_bytes_without_mac)`. Tampering with any line invalidates that line's MAC; tampering with the chain (insertion, deletion, reordering) invalidates the next `prev_mac`. The genesis anchor matters: an audit file restored on a different installation (different `installation_key`) will not validate, even if the secret matches. This is intentional — it prevents a stolen audit file from being passed off as evidence on another system. ### Why HMAC and not plain SHA-256 A plain hash chain seeded from a publicly known value (the installation key is visible in logs and the license portal) would let anyone with write access to the file rebuild the chain after modifying a line. HMAC requires the secret stored in the database — without DB access, the chain cannot be reforged. The threat model: filesystem compromise alone does not allow forgery. DB compromise is a higher bar; if an attacker has read/write access to the database, the audit log is no longer the weakest link. ## Verifying the Chain The chain is only useful if you actually verify it. Run: ```sh hortval audit verify -f /etc/hortval/config.yml ``` The command walks every segment in write order, then the active one. It validates: 1. Each line's `mac` against `HMAC(secret, line_bytes_without_mac)`. 2. Each `prev_mac` against the previous line's `mac` (or the genesis MAC for line 1). 3. That `seq` is strictly increasing. Exit codes: | Code | Meaning | |---|---| | `0` | Chain valid (or no audit lines exist yet — fresh install) | | `1` | Chain broken. The first break is reported on stderr with file, line number, and reason. | You can override the file path: ```sh hortval audit verify -f /etc/hortval/config.yml --path /backups/2026-05/audit.log ``` This still requires the database (the secret and the installation key live there), so the override is for verifying a copy of the file alongside the live DB — not for verifying a backup on a different machine. To verify a snapshot offline, restore the DB backup alongside the audit file first. ### When to verify - **Periodically** (e.g. nightly via cron / Task Scheduler) — catch silent corruption early. - **After every restore** — confirm the audit file and the database secret are consistent. - **When investigating an incident** — confirm the timeline you are reading was not modified after the fact. ## Listing nodes that wrote to the chain Each audit line carries the `server_id` of the node that wrote it (see [Node identity](./deployment-topology#node-identity)). To list every node that has ever booted against this database — useful when investigating who wrote which lines, or before decommissioning a host: ```sh hortval audit list-servers -f /etc/hortval/config.yml ``` Output columns: `server_id`, `hostname`, `first_seen`, `last_seen` (UTC, RFC 3339). Sorted by `last_seen` descending so the most recently active node appears first. ## Storage and Backups The HMAC secret lives in the database. **Backing up the database is required** for the audit log to remain verifiable: the audit file alone is useless without the secret. Cover both in the same backup procedure — see the [Backup](../administration/backup) page. Loss of the secret means loss of verifiability for all earlier lines (the file is still readable, just no longer cryptographically anchored). New writes cannot resume the old chain; on a fresh install, the writer starts a new chain from a new genesis. ## Personal Data and RGPD `source_ip` and `user_agent` are potential personal data. Retention and the user-facing notice are the responsibility of the operator who installs and runs Hortval: - **Retention** — Hortval does not delete audit lines on its own. The retention policy is whatever your OS rotation rule keeps. - **Notice** — Mention the audit log in your service's privacy notice. - **Access controls** — The audit file is created with `0644` permissions by default. Restrict the directory if your hosting model requires it. There is no PII redaction option in v1: the goal of the audit log is forensic, and redacted entries would defeat that goal. Operators who cannot retain IPs should disable the audit log entirely (`enabled: false`) and accept the loss of forensic capability. ## Operational Notes - **Failures do not block business flow.** If a write to the audit file fails (full disk, permission error), the failure is logged via the `audit` log service and the operation continues. Audit gaps are detected by `audit verify`, not by ACME clients. - **Line size cap.** Lines are capped at 1 MiB. Events that would produce a larger line are dropped with a log entry. The cap is a defence against a misbehaving event source — legitimate events are well below 1 KiB. - **No auto-purge, by design.** Hortval never deletes an audit segment, whatever the configuration. Retention is entirely a function of your archival policy — copy closed segments to your long-term store and remove them there, never through a rotator pointed at the live directory. A duration-based retention setting is planned; until then, deleting evidence remains a deliberate operator action. --- # administration/backup.md --- sidebar_position: 4 title: Backup & restore --- # Backup & restore Hortval stores all persistent state in a single database plus a small set of files in the workdir. This page covers SQLite, the default driver. Postgres and SQL Server users should follow their standard DBA tooling — see the [Other databases](#other-databases) section. ## What to back up | Path | Contents | Backup method | |---|---|---| | `db.sqlite` (+ `-wal`, `-shm`) | Default SQLite database | `hortval backup create` (NOT raw copy) | | `/server-certificate-cache/` | Certmanager TLS bundles for the ACME endpoint | File copy | | `/fakepki/` | Fake PKI CA + key (only if running the fake authority) | File copy | | `` (or `/audit.log`) | Audit log (when enabled) | File copy | | `config.yml` | Server configuration | File copy | | Let's Encrypt cache | `tls-certificate-manager.lets-encrypt.cache-dir`, may be outside workdir | File copy if configured | The `/adcs/` directory is **transient** (CSR scratch files written by the `adcs-cli` connector, recreated on demand; the native connector writes nothing here) and must not be in your backup set. The DB file itself must never be copied raw with the server running: the `-wal` and `-shm` companion files contain uncommitted writes and the result is corrupt. Always use `hortval backup create`, which calls SQLite's `VACUUM INTO` to produce a self-contained snapshot. ## `hortval backup create` ``` hortval backup create -f --output [--check none|quick|full] (default: quick) [--allow-incomplete] ``` The command: 1. Loads the same config as the running server. 2. Runs the integrity check selected by `--check`. 3. If the check fails and `--allow-incomplete` is not set: exits non-zero without producing a file. 4. If the check fails and `--allow-incomplete` is set: prints a warning, produces the backup, and exits with code `2` (distinguishes from a clean success). 5. Runs `VACUUM INTO` against the destination path. SQLite refuses to overwrite an existing destination — pick a fresh filename. ### Choosing `--check` | Level | SQL | Catches | Cost | |---|---|---|---| | `none` | (skip) | nothing | ~0 | | `quick` | `PRAGMA quick_check` | B-tree, page, header corruption (~95% of issues) | 30–50% of full | | `full` | `PRAGMA integrity_check` | + cross-checks indexes/data, FK validity | seconds on a nominal DB | **No hard write-lock during the check.** In WAL mode an integrity check is a read transaction — writers continue working. The actual cost is *WAL pressure*: the checkpoint is deferred while the check runs, so the `-wal` file grows, marginally slowing writes after several hundred MB. Negligible for a nominal Hortval database. Recommended cadence: - `quick` every 4 hours during business hours - `full` once a day (typically overnight) - `none` only when paired with `backup verify` run asynchronously on the produced file ### `--allow-incomplete` Use this when a database is degrading and you want a snapshot for forensic purposes even if integrity checks fail. The exit code `2` is the signal that the backup exists but is suspect — your scheduled task should treat it as a distinct outcome from `0` (clean success) and `1` (no backup produced). ## `hortval backup verify` ``` hortval backup verify --input [--check quick|full] (default: full) [--schema] ``` Runs against a backup file directly, without needing the server's config: - Opens the file in read-only mode. - Runs the integrity PRAGMA selected by `--check`. - With `--schema`: also checks that the canonical Hortval tables exist (catches "this is not a Hortval DB" or partial backup). - Exits `0` on success, non-zero with a message on stderr otherwise. This enables a fast-backup pattern: run `backup create --check none` for speed, then verify the produced file in the background: ```bash hortval backup create -f config.yml --output backup.sqlite --check none hortval backup verify --input backup.sqlite --check full --schema ``` ## Procedure example (Windows Task Scheduler) ```bat 1. hortval.exe backup create -f C:\hortval\config.yml ^ --output D:\backups\db.sqlite 2. xcopy C:\hortval\workdir\server-certificate-cache D:\backups\server-certificate-cache /E /I /Y 3. xcopy C:\hortval\workdir\fakepki D:\backups\fakepki /E /I /Y 4. copy C:\hortval\workdir\audit.log D:\backups\audit.log 5. copy C:\hortval\config.yml D:\backups\config.yml ``` Steps 3–4 are conditional: skip step 3 if you are not using the fake PKI, skip step 4 if no audit log is configured. If your Let's Encrypt cache is outside the workdir, copy it too. ## Restore (manual procedure) There is no `restore` subcommand in v1: the procedure is a few file moves. 1. Stop the service: `Stop-Service Hortval`. 2. Move the failed `db.sqlite`, `db.sqlite-wal`, `db.sqlite-shm` aside (do **not** delete them yet). 3. Copy the backup's `db.sqlite` to the workdir. 4. **Delete any stale `-wal` and `-shm` files** at the destination — those from the failed instance are no longer consistent with the restored DB. 5. Restore `server-certificate-cache/` and (if applicable) `fakepki/` from the backup. 6. Restore `audit.log` to its configured path. 7. Start the service: `Start-Service Hortval`. 8. Tail the startup logs to confirm the license, schema migrations, and first request all succeed. 9. `hortval audit verify` to confirm the audit log MAC chain end-to-end. ## 3-2-1-1 baseline A safe backup posture for a production Hortval: - **3** copies of the data - on **2** different storage media - with **1** copy offsite - and **1** copy offline or immutable A test restore on a regular cadence (quarterly minimum) is the only way to prove your backup chain actually works. A backup that has never been restored is a hope, not a backup. ## Other databases ### PostgreSQL Out of scope for `hortval backup`. Use standard PostgreSQL tooling: - `pg_dump` for logical, point-in-time snapshots that are portable across PG versions. - `pg_basebackup` plus WAL archiving for physical backups with PITR. The workdir (certificate cache, fakepki, audit log, config) still needs the file-copy procedure above; only step 1 changes. ### SQL Server Out of scope for `hortval backup`. Use standard SQL Server tooling: - `BACKUP DATABASE` T-SQL with maintenance plans driven by SQL Server Agent. - Full / differential / log backup chains depending on your RPO. The workdir procedure is identical to SQLite. ## PII and retention The Hortval database and audit log contain potentially personal data (account contact addresses, validation source IPs, user agents). Backups are subject to the same data-protection obligations as the live data — encrypt them at rest, restrict access, and apply a retention policy that matches your compliance requirements. --- # administration/cold-start.md --- sidebar_position: 9 title: Cold-start --- # Cold-start When you want to bring Hortval up before a license is installed — typical during a fresh install, while a free or paid license is being procured, or during evaluation — you open a **cold-start window**. Cold-start is an explicit action: you choose the plan you want to evaluate under, the binary writes the window into the database, and `hortval serve` then boots normally against the plan's limits. The window lasts **1 week** and is extendable while you finish the onboarding. Cold-start is the only way to run the server without an installed license. Past the cold-start window — and once any cumulative cap is reached — only installing a real license restores normal boot. ## Opening the window ```bash hortval cold-start init --plan= -f config.yml ``` The chosen plan determines the constraints that apply during the window — allowed database drivers, maximum number of authorities, managed-server cap. See [Plans](../intro/plans.md) for the per-plan limits. Picking a plan that does not match your configuration is refused immediately: an operator who configures Postgres and asks for `free` (which only allows SQLite) gets a clear error pointing at the mismatch, rather than the server booting and refusing every certificate request later. `cold-start init` refuses when: - A license is already installed — uninstall it first if you really want to start over, or use one of the regular license commands. - Cold-start has already been initialised on this installation — see "Extending the window" to renew the deadline, or "Correcting the plan" if you picked the wrong plan. - The running configuration cannot be honored by the chosen plan. Example: ``` $ hortval cold-start init --plan=pro -f config.yml Cold-start initialised. Plan : pro Window ends: 2026-06-14 13:23 UTC (168h0m0s remaining) You may now start the server normally: hortval serve -f ``` ## Inspecting the window ```bash hortval cold-start status -f config.yml ``` `cold-start status` is read-only. It prints the current plan, the deadline, the remaining time, whether a cumulative cap applies (and how much of it is left), and — when a real license is already installed — a note that cold-start no longer applies. Use it whenever you want to confirm what mode the server is in and how much time is left before the next deadline. ## Correcting the plan If you picked the wrong plan at `cold-start init` (typically a typo between `pro` and `enterprise`, or a misjudged sizing during evaluation), you can change it without waiting out the window: ```bash hortval cold-start switch --plan= -f config.yml ``` `cold-start switch` updates the active plan and leaves the window deadline untouched. There is no `--confirm` flag — the action is atomic and has no preview side-effect. The new plan's constraints take effect at the next `hortval serve` boot (and at the next online check for already-running instances). `cold-start switch` refuses when: - A license is already installed — use `license install` / `register` / `refresh` instead, which can replace the plan entirely. - Cold-start was never initialised on this installation — run `cold-start init` first. - The requested plan equals the current plan — nothing to do. - The configuration cannot be honored by the requested plan (e.g. switching from `pro` to `starter` while the configured database driver is `postgres`, or while you have declared more authorities than the target plan allows). Adjust your config or pick a different plan. The window deadline is **never reset** by a switch — that protects against using `switch` as a way to renew the window indefinitely. Use `cold-start extend` for that, which has its own 3-week cap. ## Extending the window If the cold-start window is about to elapse and you still need more time — e.g. the license is on its way but has not arrived yet — open a new 7-day window: ```bash # Preview only — shows what would change, writes nothing. hortval cold-start extend -f config.yml # Actually opens a new 7-day window. hortval cold-start extend --confirm -f config.yml ``` `cold-start extend` is intentionally a separate, deliberate action so that running the server without a license is always a conscious decision. ### Cumulative cap Extensions are bounded by a **3-week cumulative cap** measured from the first time your installation served an ACME client (a tamper-evident reference — the cap cannot be reset by clearing local state). Until your installation has served any client, the cap does not apply and extensions may be re-invoked freely while you finish setup. Once the cap is reached, the only way forward is to install a real license. The portal can help with a trial license if a paid plan is in flight. `cold-start extend` refuses when: - A license is already installed — the server is no longer in cold-start. - Cold-start was never initialised on this installation — run `cold-start init` first. - The cumulative cap has been reached. ## What applies during cold-start The runtime constraints are the same as for a real license at the same plan level: - The allowed database drivers must include the one you configured. - The number of declared authorities must not exceed the plan's cap. - New ACME accounts beyond the plan's managed-server cap are refused with HTTP 403 and a `license.deny` audit event. These are checked at boot and on every new order. See the [License enforcement page](license-enforcement.md) for the full enforcement surface and the audit events emitted for refused orders. ## Leaving cold-start Cold-start ends the moment a valid license is installed. Any of the three license commands clears the cold-start window automatically: ```bash hortval license register --env -f config.yml hortval license install -f config.yml /path/to/your.lic hortval license refresh -f config.yml ``` After installing the license, `hortval serve` boots against the license's own constraints. The cold-start state is cleared as part of the install operation — there is no separate clean-up command. ## Audit events | Event | When | Decision | Key details | |---|---|---|---| | `cold_start.init` | An operator ran `cold-start init --plan=`. One event per successful initialisation. | `allow` (`reason=operator_init`) | `plan`, `expires_at`, `installation_key` | | `cold_start.init_rejected` | `cold-start init` was refused because the configuration does not fit the chosen plan. | `deny` (`reason=config_mismatch`) | `plan`, `driver`, `configured_cas`, `reasons` | | `cold_start.switch` | An operator ran `cold-start switch --plan=`. One event per successful switch. The window deadline is unchanged. | `allow` (`reason=operator_switch`) | `from_plan`, `to_plan`, `expires_at` | | `cold_start.switch_rejected` | `cold-start switch` was refused. The `reason` field tells you which precondition failed. | `deny` (`reason=license_installed` \| `not_initialised` \| `same_plan` \| `unknown_plan` \| `config_mismatch`) | `plan`, plus on `config_mismatch`: `driver`, `configured_cas`, `reasons` | | `cold_start.extend` | An operator ran `cold-start extend --confirm`. | `allow` (`reason=operator_extend`) | `plan`, `anchor`, `cap_at`, `expires_at` | Runtime refusals (`license.deny`, `license.boot_refused`, …) use the same event names whether the binary is in cold-start or running against a real license. See [License enforcement / Audit events](license-enforcement.md#audit-events). The audit log is tamper-evident. Use `hortval audit verify` to validate the chain end-to-end (see [Audit log](audit.md)). ## Example session A fresh install, evaluating against the Pro plan, with a license to come within the week: ``` $ hortval cold-start init --plan=pro -f /etc/hortval/config.yml Cold-start initialised. Plan : pro Window ends: 2026-06-14 13:23 UTC (168h0m0s remaining) You may now start the server normally: hortval serve -f $ hortval cold-start status -f /etc/hortval/config.yml License : not installed. Cold-start plan: pro Window ends : 2026-06-14 13:23 UTC Remaining : 6d23h Cap : none (setup phase — no ACME account yet) # A week later, the license has not arrived yet — open a new window. $ hortval cold-start extend --confirm -f /etc/hortval/config.yml Cold-start extend evaluation Current plan : pro Current window: ends 2026-06-14 13:23 UTC Anchor : first_acme_account Anchor at : 2026-06-10 09:15 UTC Global cap : 2026-07-01 09:15 UTC New window : until 2026-06-21 13:30 UTC Cold-start window extended until 2026-06-21 13:30 UTC. # License arrives — register it. $ hortval license register --env prod -f /etc/hortval/config.yml CRT-… License registered — plan=pro … ``` Once the license is registered, the cold-start window is cleared and `hortval serve` runs under the license's own constraints. --- # administration/deployment-topology.md --- sidebar_position: 1 title: Deployment topology --- # Deployment topology Hortval is designed and supported as a **single-instance** deployment. This page documents which topologies are supported today, and which ones will silently break your installation if you try. ## Supported ### Single instance (recommended) One Hortval process on one host, with its own database. This is the recommended production topology. ``` ┌──────────────┐ HTTPS ┌──────────────┐ RPC ┌──────────┐ │ ACME clients │ ──────────► │ Hortval │ ───────────► │ ADCS │ └──────────────┘ └──────────────┘ └──────────┘ │ ▼ ┌──────────────────┐ │ Database │ │ SQLite / PG / │ │ SQL Server │ └──────────────────┘ ``` This covers the vast majority of enterprise PKI volumes. A single Hortval instance on a modest Windows Server processes several certificate orders per second. ### Cold Active / Passive (manual switchover) You can install Hortval on two hosts for failover, **as long as only one instance is running at a time**. The standby is fully stopped (process not running, port not bound). The administrator switches manually : stop the active node, then start the standby. ``` ┌──────────────────────────────────────────┐ │ VIP / LB │ └──────────────────┬───────────────────────┘ │ HTTPS ┌──────────┴──────────┐ ▼ ▼ ┌────────┐ ┌──────────┐ │ Node A │ │ Node B │ │ ACTIVE │ │ STOPPED │ └────┬───┘ └──────────┘ │ ▼ ┌────────────────────────────────────┐ │ Shared DB (PostgreSQL / SQL Server)│ └────────────────────────────────────┘ ``` Requirements for this topology : - **Database must be PostgreSQL or SQL Server.** SQLite is **NOT supported** for any multi-host setup: its file-level locking is not reliable across hosts on shared filesystems (NFS, SMB, etc.) and corruption is a matter of when, not if. - Each node has its **own local work directory** (TLS cache, transient files, audit log). The work directory does not need to be shared. - The administrator owns the switchover discipline: **never start the standby before the active is fully stopped.** Starting two instances against the same database is the unsupported Active / Active topology described below — pathologies will appear silently. Switchover procedure: 1. Stop Hortval on the active node (graceful shutdown drains in-flight ACME requests). 2. Start Hortval on the standby node. 3. Update your load balancer to route to the new active node. Expected switchover time: typically under a minute, including the standby's boot probe. ### Load balancer in front of a single instance A reverse proxy or load balancer in front of a single Hortval instance — for TLS termination, IP filtering, geo-routing, etc. — is fully supported. Forward the `Host` header and preserve the client IP if your audit log relies on it. ## Node identity Each Hortval instance has a stable identifier called `server_id`. It is materialised on first boot as a UUID v4 stored in `/server_id` (file permissions `0o600`) and registered in the `servers` table of the database. Every subsequent boot of the same node reads back the same `server_id` and updates the `last_seen` timestamp; a background heartbeat refreshes it once per minute while the instance runs. Two operator-visible consequences: - **Each line of the audit log carries the `server_id` of the node that wrote it.** When a node opens its audit log at boot, it checks the last line. If that line was written by a different `server_id`, the node **refuses to start** with an explicit error message naming both identifiers. This is intentional: it prevents an operator from accidentally pointing two nodes at the same audit file and silently splicing two histories. - **Cold Active / Passive works naturally with this model.** Each node has its own workdir, its own `/server_id`, and its own `/audit.log`. The database is shared, but the audit chain is per-node. The `servers` table will accumulate one row per host that has ever booted against this database — useful for operators tracking which nodes participated in the cluster over time. Do **not** copy a workdir from one host to another. Each new host should generate its own `server_id` on first boot — that is the point of the marker file. If you ever need to inspect or decommission a known node, list them with: ``` hortval audit list-servers -f config.yml ``` ## NOT supported — Active / Active Running two or more Hortval instances **concurrently** against the same database **is not supported** in the current release. Several core mechanisms hold in-process state that does not coordinate across nodes: | Subsystem | What breaks under Active / Active | |---|---| | **ACME nonces** | Each instance generates nonces with its own secret and tracks them in local memory. A client whose first request lands on node A and second request lands on node B is rejected with `badNonce`, forcing a fresh `newNonce` call every two requests. Replay protection is also local per node — the same nonce can be rejected by one node and accepted by another within its 2-minute TTL. | | **Rate limiting** | Several limits (failed-validation back-off, account-creation throttling, order-creation throttling, global) are in-memory per instance. A client hitting two nodes can effectively double its quota. | | **License enforcement** | Per-instance counters for `max_managed_servers`. Two instances can both believe they are under the limit while the cluster as a whole has already exceeded it. | | **PKI health checks** | Each instance pings the configured CAs independently. Operationally noisy in logs, not corruption-inducing. | | **TLS certificate manager** | Each instance maintains its own server-certificate cache on disk. Two instances behind the same hostname will fetch or issue their TLS cert independently — risk of double-consuming a Let's Encrypt quota or returning different chains depending on which node a client lands on. | **Failure modes are silent and intermittent.** Clients see sporadic `badNonce` errors, rate limits feel inconsistent, license counters drift, certificate behaviour depends on which node terminates the TLS handshake. Diagnosing these in production after the fact is painful. ### Why sticky sessions do not fix this A load balancer with session affinity (cookie-based) does **not** solve the nonce problem in practice : the standard ACME clients (lego, certbot, acme.sh, native Go clients) do not enable an HTTP cookie jar for their ACME requests, so a `Set-Cookie` from the load balancer is ignored. Source-IP affinity is more reliable but breaks the moment clients sit behind a NAT, a corporate proxy, or a CGNAT. If you need true multi-node availability today, use **cold Active / Passive** above and accept the manual switchover. True warm Active / Passive and Active / Active deployments are tracked on the [public roadmap](../intro/roadmap.md) for v2.0 Enterprise. ## Database backend behind a single instance Independently of the Hortval topology, the database tier can run its own HA setup: - **SQLite WAL** — concurrent readers + single writer. Adequate for single-instance Hortval. Not usable across hosts. - **PostgreSQL with replication** — primary + read replicas for backup and reporting. Hortval only writes to the primary. Database-level failover (e.g. `pg_auto_failover`, Patroni) is transparent to Hortval as long as the connection string resolves to the new primary after the cut. - **SQL Server with Always On / mirroring** — same principle. Hortval connects to one target ; failover at the database tier is handled by the listener / cluster role. In all cases, scaling the database tier does **not** unlock Active / Active for Hortval itself. --- # administration/license-enforcement.md --- sidebar_position: 7 title: License enforcement --- # License enforcement Hortval enforces the limits associated with your active license at two moments: - **At boot**, against your configuration and the current state of the database (number of declared authorities, database driver in use, number of distinct accounts already serving certificates). - **At runtime**, on every new order, against the live state. If your configuration exceeds what the active plan allows, the server refuses to start until you explicitly acknowledge the situation. This is intentional: a silent downgrade (for example after an automatic license renewal moves you to a smaller plan) would otherwise only show up later as ACME clients receiving `403` responses. ## What is checked | Limit | Source | Boot check | Runtime check (new orders) | |---|---|---|---| | Allowed database drivers | Plan | ✅ vs `database.driver` | ✅ | | Maximum authorities | Plan | ✅ vs number of declared authorities | ✅ | | Maximum managed servers | Plan | ✅ vs distinct active accounts | ✅ | A "managed server" is a distinct ACME account with at least one active (non-expired, non-revoked) certificate. Re-issuances and renewals from the same account do not consume additional quota. See the [Plans page](../intro/plans.md) for the per-plan limits. ## Boot behaviour Three outcomes are possible at startup, depending on whether the configuration matches the license: ### Conforming — silent boot Everything is within the plan. The server starts normally and any previous acknowledgement (see below) is cleared. ### Cold start (no license yet) Bringing the server up before a license is installed is an explicit action. You open a 1-week cold-start window against the plan you intend to evaluate under: ``` hortval cold-start init --plan=starter -f config.yml ``` The constraints of the chosen plan (allowed database drivers, maximum authorities, managed-server cap) apply during the window — exactly as they would with a real license at that plan level. Once the window is open, `hortval serve` boots normally. When the window is about to elapse and the license has not yet arrived, extend it for another 7 days: ``` hortval cold-start extend --confirm -f config.yml ``` Extensions are bounded by a 3-week cumulative cap once your installation has served any ACME client. See the dedicated [Cold-start page](cold-start.md) for the full action surface, including the read-only `cold-start status` diagnostic. ### Degraded — boot refused until acknowledged If a license is installed and the configuration exceeds it, the server refuses to start. Two outputs are produced in parallel: - A **structured JSON log** at `ERROR` level, suitable for journald / Grafana / SIEM ingest. - A **plain-text banner on stderr** so an operator running the binary interactively immediately sees what went wrong without having to parse JSON. The banner includes the list of failing reasons and the available recovery actions. Example banner: ``` ============================================================================== HORTVAL — BOOT REFUSED (LICENSE DEGRADED) ============================================================================== Installation key: INST-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX License key: CRT-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX max_cas_exceeded db_not_allowed What to do: 1. Acknowledge and start in degraded mode: hortval license acknowledge-degraded -f 2. Or fix your configuration to fit the license (fewer CAs, allowed DB driver, ...) 3. Or upgrade your plan at https://hortval.com/portal/dashboard ============================================================================== ``` The **Installation key** and **License key** lines are present on every banner so a screenshot of the failed boot is self-sufficient when contacting support — no need to dig through the logs to find them. You have three options: 1. **Fix the underlying problem** — reduce the number of authorities, change the database driver, or upgrade your plan, then restart. 2. **Acknowledge the degraded state** — run `hortval license acknowledge-degraded`. The server will then start, with a clearly visible warning, and continue serving renewals (see [Runtime behaviour](#runtime-behaviour) below). 3. **Use force-grace** as a temporary escape hatch (see [Force-grace](#force-grace-one-shot-escape-hatch) below). ## Acknowledging a degraded state ``` hortval license acknowledge-degraded -f config.yml ``` This command: - Reads the active license and the relevant configuration. - Refuses if the configuration is **not** in a degraded state — there is nothing to acknowledge in that case. - Refuses if no license is installed yet — install your license first, then re-run the command. - Otherwise persists the acknowledgement in the database and prints a summary, including the list of reasons and the timestamp. The acknowledgement is **not** a permanent waiver. It is bound to the specific combination of plan limits and configuration that triggered it. The acknowledgement expires automatically when **any** of the following changes: - The plan changes (license renewal moves you to a larger or smaller plan). - The number of declared authorities changes. - The database driver changes. A change in the **number of managed servers** alone does **not** invalidate the acknowledgement. Counts naturally fluctuate with certificate expirations and revocations; if a previously degraded count drops back under the cap, the next boot is silent and the acknowledgement is cleared on its own. If a future boot is conforming, the stored acknowledgement is removed so that any new degradation later requires a fresh, explicit acknowledgement. ## Installing a license that does not fit the configuration `hortval license install ` and `hortval license refresh` refuse to apply a new license whose entitlements do not match the running configuration. The previous state is preserved untouched. The CLI prints the reasons and three options: - Fix the configuration, then re-run. - Pick a different license that fits. - Re-run with `--force` to install anyway. The next `hortval serve` will refuse to start until you also run `hortval license acknowledge-degraded` (see above). The watcher's online auto-refresh applies the same check — a renewed license that does not match is **not** silently applied; the previously installed payload remains in effect and a `license.refresh_rejected` audit event is recorded. `hortval license register ` is validated at the portal before any binding happens. Mismatches are returned as a `400` with a clear message; you can immediately retry with a different key. There is no `--force` flag on `register`. ## Force-grace (one-shot escape hatch) When your installed license has expired beyond the 7-day post-expiry window and you need immediate breathing room — a renewal is in flight, the portal had an outage, etc. — you can open a 7-day boot window with: ``` hortval license force-grace -f config.yml # preview, nothing is written hortval license force-grace -f config.yml --confirm # actually opens the window ``` Without `--confirm`, the command only **previews** what would happen and exits without writing anything. The preview shows the anchor, the global cap, and the window the binary would receive. Force-grace is intentionally narrow: it is only for a real license that has expired past its grace. Other situations have their own dedicated paths and are not covered: - **No license installed yet** — open a cold-start window with `hortval cold-start init --plan=` (see [Cold-start](cold-start.md)). - **License revoked** — revocation is an explicit decision from the portal; contact support instead. - **License signature invalid** — the stored file is corrupted or tampered with; this should be investigated, not bypassed. - **Degraded configuration with a valid license** — use `hortval license acknowledge-degraded` instead. Force-grace is for license problems, not configuration problems. ### The 3-week cap Each force-grace invocation grants up to **7 days**, and may be re-invoked weekly. The cumulative time the binary can run under force-grace is capped at **3 weeks** measured from the license expiry date signed in your `.lic` (tamper-evident: the signature cannot be rewritten without breaking verification). Once your license has been expired for more than three weeks, only installing a fresh license restores normal boot. ### Safety properties - **Quota enforcement stays active.** Force-grace bypasses the boot refusal, not the runtime checks. New orders are still refused if they exceed the cap on managed servers or use a disallowed database driver. The existing payload constraints (or the cold-start defaults if no parseable payload exists) continue to apply. - **Defense in depth at boot.** The eligibility rules are re-checked on every boot, not only at the moment the sub-command is invoked. If the persisted force-grace deadline has been altered to exceed the cap, or the underlying license problem has been resolved between the activation and the boot, the binary ignores the flag. - **Auto-reset.** Installing a valid license clears the force-grace state automatically. The watcher also picks up a mid-window install and swaps the in-memory payload without requiring a restart. ### Example session ``` $ hortval license force-grace -f config.yml --confirm Force-grace evaluation License state : license_expired Anchor : license_expiry Anchor at : 2026-05-26 12:00 UTC Global cap : 2026-06-16 12:00 UTC Window grants : until 2026-06-09 18:43 UTC Force-grace activated until 2026-06-09 18:43 UTC. You may now start the server normally: hortval serve -f ``` At boot, a clearly visible banner is printed on stderr: ``` ============================================================================== HORTVAL — RUNNING IN FORCE-GRACE (7-DAY WINDOW) ============================================================================== Installation key: INST-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX License key: CRT-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX Underlying license problem: license_expired Force-grace expires: 2026-06-09 18:43 UTC Force-grace is a ONE-SHOT escape hatch — once consumed, it cannot be re-activated. Existing quota constraints still apply to new orders (CAs, managed servers, allowed DBs). ... ``` The boot logs also include a `FORCE-GRACE ACTIVE` warning line that gets picked up by structured-log ingest. ## Boot banner reference When the server cannot start (or has to start in a degraded mode), the binary prints a framed banner on stderr in addition to the structured JSON logs. Every banner carries your **Installation key** and **License key** so the message is self-sufficient for support. The table below lists the cases an operator can hit during normal operation and how to recover from each. | Banner title | When you see it | How to recover | |---|---|---| | `BOOT REFUSED (NO LICENSE, NO COLD-START)` | Fresh installation: no license has been installed and no cold-start window has been opened. | Open a cold-start window with `hortval cold-start init --plan=` while you procure a license, or install one directly with `hortval license register ` or `hortval license install `. See [Cold-start](cold-start.md). | | `BOOT REFUSED (COLD-START WINDOW EXPIRED)` | A cold-start window had been opened but elapsed without a license being installed. | Open a new 7-day window with `hortval cold-start extend --confirm`, or install your license now with `hortval license register` / `... install`. The extend command may be refused once the 3-week cumulative cap has been reached — installing a license is then the only path forward. | | `BOOT REFUSED (COLD-START PLAN MISMATCH)` | A cold-start window is open but the running configuration cannot be honored by the chosen plan (database driver not allowed, or too many authorities declared). | Reduce the configuration to fit the plan (e.g. switch to SQLite, drop an authority), or re-bootstrap with a plan that matches the configuration. | | `BOOT REFUSED (LICENSE EXPIRED)` | Your installed license has been expired for more than the 7-day post-expiry grace. | Pull the renewed license with `hortval license refresh`, install the new file manually with `hortval license install`, register a fresh key with `hortval license register`, or open a 7-day window with `hortval license force-grace --confirm`. | | `BOOT REFUSED (LICENSE DEGRADED)` | A valid license is installed, but the current configuration exceeds what your plan allows (CAs declared, database driver, managed-server count). | Either fix the configuration, upgrade your plan at the portal, or acknowledge the situation with `hortval license acknowledge-degraded` to start in degraded mode (renewals continue, new orders are refused). | | `BOOT REFUSED (LICENSE ERROR)` | Catch-all for an unrecognised license problem. | Check the structured log line just above the banner for context; `hortval license refresh` is a safe first attempt. | | `RUNNING IN FORCE-GRACE` | Information banner — the server is booting under an open force-grace window. | Resolve the underlying license problem before the window closes (`hortval license refresh`, `... install`, or `... register` with a renewed key). The window is one-shot per cap period.| Other banner titles exist for situations that indicate license file tampering or revocation by the portal. If you encounter one of those, the recovery is to contact support via the form on the portal. ## Runtime behaviour Even after a degraded boot has been acknowledged, the server still enforces the license on every new order: - **Renewals are always accepted.** A renewal is identified as a new order for the same canonical FQDN set already covered by an active certificate for that account. This guarantees existing clients keep functioning while you fix your configuration. - **New certificates are refused** with HTTP 403 and an audit log entry (`license.deny`) when: - The active database driver is not allowed by the plan. - The number of declared authorities exceeds `MaxCAs`. - The new order would create a managed server beyond `MaxManagedServers`. - The plan grants no managed-server entitlement at all. The audit event includes the reason, the account identifier, the source IP, and a `details` payload describing the limit that fired. See the [Audit log page](audit.md) for how to query and verify the audit chain. ## Reasons reference | Reason | When it fires | |---|---| | `db_not_allowed` | The configured `database.driver` is not in the plan's allowed list. | | `max_cas_exceeded` | More authorities are declared in configuration than the plan allows. | | `managed_servers_exceeded` | The number of distinct accounts with active certificates is above the plan's cap. | | `max_managed_servers_zero` | The active license grants no managed-server entitlement at all. | ## Audit events Every license-enforcement decision is recorded in the [audit log](audit.md) so that compliance and forensic review can reconstruct what happened without relying on operator memory or rotated stdout logs. The following events are emitted: | Event | When | Decision | Key details | |---|---|---|---| | `license.boot_refused` | The server refused to start. Emitted with one of several reasons: a configuration degraded against a valid license with no acknowledgement, a cold-start window that has elapsed, a cold-start plan mismatch, or "no license and no cold-start window" on a fresh install. | `deny` | `reason`, `reasons` (when degraded), `plan` / `driver` / `configured_cas` (when cold-start) | | `license.boot_degraded` | The server started in degraded mode against a valid acknowledgement. One event per boot. | `allow` (`reason=ack_active`) | `reasons`, `hash`, `acknowledged_at` | | `license.acknowledge` | An operator ran `hortval license acknowledge-degraded`. | `allow` (`reason=operator_ack`) | `reasons`, `hash`, `hostname` | | `license.install_rejected` | `hortval license install` or `... register` refused to persist a new license because its entitlements do not match the running configuration. The `--force` flag overrides this refusal. | `deny` (`reason=config_mismatch`) | `source`, `plan`, `driver`, `configured_cas`, `reasons` | | `license.refresh_rejected` | A refreshed license was rejected because it does not match the running configuration. Emitted both for the manual `hortval license refresh` path and for the watcher's online auto-refresh path; the `source` field distinguishes them. | `deny` (`reason=config_mismatch`) | `source` (`cli` or `watcher`), `plan`, `driver`, `configured_cas`, `reasons` | | `license.installation_mismatch` | A stored license is bound to a different installation than this one. The server refuses to use it. | `deny` (`reason=installation_key_mismatch`) | `license_key`, `installation_key` | | `license.force_grace` | An operator consumed a force-grace window via `hortval license force-grace --confirm`. | `allow` (`reason=operator_forced`) | `state`, `expires_at`, `cap_at` | | `license.force_grace_boot` | The server booted under an active force-grace window. One event per boot. | `allow` (`reason=force_grace_active`) | `state`, `expires_at` | | `license.force_grace_expired` | The active force-grace window elapsed mid-run; the server is stopping. | `deny` (`reason=force_grace_window_elapsed`) | `expired_at` | | `license.deny` | A new order was refused at runtime because of a license limit. One event per refused request. | `deny` (`reason` = the failing limit) | `reasons` and the offending values (driver, current count, max, etc.) | | `license.change` | The license state transitioned (`valid` ↔ `grace` ↔ `expired` ↔ `revoked` ↔ `no_license`). | `allow` | `from`, `to` | | `cold_start.init` | An operator ran `hortval cold-start init --plan=` and the window opened. | `allow` (`reason=operator_init`) | `plan`, `expires_at`, `installation_key` | | `cold_start.init_rejected` | `cold-start init` was refused because the configuration does not match the chosen plan. | `deny` (`reason=config_mismatch`) | `plan`, `driver`, `configured_cas`, `reasons` | | `cold_start.extend` | An operator opened a new 7-day window via `hortval cold-start extend --confirm`. | `allow` (`reason=operator_extend`) | `plan`, `anchor`, `cap_at`, `expires_at` | The audit log is tamper-evident (HMAC chain anchored on the installation identifier). Use `hortval audit verify` to validate it. See the [Audit log page](audit.md) for the file format and rotation behaviour. ## Example acknowledgement output ``` $ hortval license acknowledge-degraded -f config.yml License degradation acknowledged. Reasons: [db_not_allowed max_cas_exceeded] Hash: 7a3f...e1b9 At: 2026-05-10 14:05:22 UTC The ack will expire automatically if constraints or configuration change. ``` The hash is shown for support and operational diagnostics; you do not need to record it. Operators typically run this command once after a downgrade, then schedule the configuration fix or plan upgrade as a follow-up. --- # administration/logging.md --- sidebar_position: 1 title: Logging --- # Logging Hortval uses structured logging with configurable level, format, output, and per-service overrides. :::caution Startup lines go to stderr, not to `logs.file` The configured destination is installed once the configuration has been read, so the lines emitted before that point are written to **stderr** and never appear in `logs.file`. When startup is *refused*, the log file is not created at all and everything — the JSON line and the human-readable message — goes to stderr. Keep stderr captured wherever Hortval runs. Under systemd it is routed to journald by default, so nothing is lost, only split across two places. Under the Windows Service Control Manager stderr is attached to nothing and those lines are lost; see [Installation](../getting-started/installation.md) for what that means today. ::: ## Configuration ```yaml logs: level: info format: json output: file file: "C:\\ProgramData\\hortval\\logs\\hortval.log" rotate: max-size-mb: 100 max-backups: 10 services: DB-Driver: warn acme-server: debug tags: instance: cert-srv-01 region: eu-west ``` ## Fields | Field | Default | Description | |---|---|---| | `level` | `info` | Global log level: `debug`, `info`, `warn`, `error`, `off`. `off` (alias `none`) fully suppresses logs and is most useful as a per-service override. | | `format` | `json` | Log format: `json` or `text` | | `output` | `stderr` | Output destination: `stderr`, `stdout`, or `file` | | `file` | — | Log file path. Required if `output: file`. With rotation enabled it is a **naming base**, not a file — see [Rotation](#rotation). | | `rotate.max-size-mb` | `0` | Size at which a new segment is started. `0` disables rotation: a single file grows indefinitely. | | `rotate.max-backups` | `5` | Number of **closed** segments kept. `0` keeps only the one being written; `-1` never deletes. | | `services` | empty | Per-service log level overrides | | `tags` | empty | User-defined labels added to every log entry — useful for Grafana/Loki filtering | ## Rotation When `output: file` and `rotate.max-size-mb` is greater than zero, `file` is a **naming base**, not a file. Hortval writes dated segments beside it and never renames them: ``` file: C:\ProgramData\hortval\logs\hortval.log C:\ProgramData\hortval\logs\ hortval.20260726T091702Z.ms123.log <- closed hortval.20260726T104417Z.ms008.log <- being written ``` The suffix is a UTC timestamp, so the alphabetical order of the file names is their chronological order. A closed segment is never reopened, which is what makes it safe to copy or archive. **Configure your log collector with the folder and a `*.log` pattern**, not with the path in `file`. To follow the live file interactively: ```powershell Get-Content -Wait (Get-ChildItem C:\ProgramData\hortval\logs\hortval.*.log | Sort-Object Name | Select-Object -Last 1) ``` If a rotation cannot complete — an antivirus holding the file, a full disk, a permissions problem — Hortval keeps writing to the current segment, reports the reason on stderr (captured by the Windows service manager), and retries later. Nothing is lost. `max-backups` bounds disk usage at roughly `max-backups × max-size-mb`. It defaults to **5**; before v0.9.4 it defaulted to `0`, which meant "keep nothing", so enabling rotation silently discarded the log at each rotation. :::note Permissions On Windows, access to the log files is governed by the **NTFS permissions inherited from the containing folder** — set that folder's ACL at install time. On Linux and macOS, files are created `0600` and the directory `0700`, so a collector running under its own account cannot read them without being granted access out of band. ::: ## Per-Service Log Levels You can set a different log level for each internal service. This is useful for debugging a specific component without flooding logs with debug output from everything else. ```yaml logs: level: info services: acme-server: debug Async-Acme-Challenges: debug ``` Use `off` (or `none`) to fully silence a service — for example when a chatty driver is generating noise during dev or staging captures: ```yaml logs: services: DB-Driver: off acme-server: warn ``` ### Registered Service Names :::caution An unrecognised name stops startup Only the names below are accepted. Anything else — a typo, or a name from a release before 0.9.5 — is refused at startup and by `hortval validate`, with the accepted list in the message. Until 0.9.5 it was silently ignored: the level fell back to the global default and nothing said so, which is the worst way for a diagnostic setting to fail. Two names changed in 0.9.5 and have **no alias** — `Certeasy-acme-server` became `acme-server`, `cert-easy-main` became `main`. ::: | Service Name | Description | |---|---| | `DB-Driver` | Database driver and query logs | | `acme-server` | ACME HTTP request handling | | `Async-Acme-Pki-Handler` | Async PKI job processing | | `Async-Acme-Challenges` | Async challenge validation | | `JWKS` | JWS key validation | | `worker` | Job engine (lease, dispatch, backoff) | | `http-server` | HTTP server lifecycle | | `adcs-native` | ADCS authority operations — native in-process connector (default) | | `adcs-cli` | ADCS authority operations — `certreq.exe` connector (`adcs-cli`) | | `license` | License lifecycle (install, refresh, enforcement) | | `node` | Node identity (`server_id`, servers table) | | `main` | Startup and shutdown: configuration, working directory, schema gate | | `audit` | Tamper-evident audit log (open, rotate, chain recovery) | | `fakepki` | Built-in test PKI — CA generation and issuance | The list is exhaustive as of 0.9.5, and it has to be: a name absent from it is refused. It is kept in step with the code by a test that scans every service registration in the codebase, so it cannot quietly fall behind. ## Tags (Grafana/Loki labels) `logs.tags` is a free-form map of `key: value` pairs added to **every** log entry. Use it to attach environment metadata that your log aggregator (Grafana/Loki, Splunk, Elastic…) can filter on. ```yaml logs: tags: instance: cert-srv-01 region: eu-west role: production ``` Each entry shows up as a top-level field in the JSON output, alongside `time`, `level`, `msg`, etc. There is no fixed list of allowed keys — pick whatever your stack expects. :::note The previous automatic `env` field is no longer added to log entries; it conflicted with the `env=` shown inside license-related log messages (license environment, e.g. `env=dev` / `env=prod`). If you want an environment label, set it explicitly under `tags`. ::: ## Log Rotation Log rotation is supported when `output: file`. Configure `rotate` to limit disk usage: ```yaml logs: output: file file: "C:\\ProgramData\\hortval\\hortval.log" rotate: max-size-mb: 100 max-backups: 5 ``` This keeps up to 5 rotated files of 100 MB each (500 MB total). ## Production Recommendations - Use `format: json` for structured log ingestion (Splunk, Elastic, Loki…) - Use `output: file` with rotation to avoid filling disk - Keep global level at `info` and only set `debug` on specific services when troubleshooting - Route logs to your SIEM — the audit log entries contain account IDs, order IDs, and operation details --- # administration/migrations.md --- sidebar_position: 3 title: Migrations --- # Migrations Hortval carries its database schema inside the binary. There are no SQL files to deploy alongside it. A **restart** applies only the migrations that adding nothing back could have survived — a new table, a new column with a default, a new non-unique index. Anything heavier waits for you to run `hortval migrate`. A restart is rarely something a human decided: a crash, a reboot or a failed health check all restart the process, and none of them is a moment when someone is standing by with a backup. :::info No breaking migration has shipped yet Every migration in Hortval to date is additive, on all three drivers. A restart applies them, and the refusal described below is not something you will meet on an upgrade today. It exists so that the first non-additive change — whenever it comes — cannot land as a side effect of a process restarting. ::: ## Upgrading Replace the binary and start Hortval. In the common case it applies what is missing and starts. If it refuses, the message names what is pending: ``` REFUSED: 1 of the 3 pending migration(s) cannot be undone by doing nothing. Back up the database first: hortval backup create -f --output (SQLite) your DBA's procedure (PostgreSQL / SQL Server) ``` Back up first. On SQLite, Hortval ships the command — it snapshots the database and runs an integrity check on the result: ```bash hortval backup create -f /etc/hortval/config.yml --output /backup/hortval.sqlite ``` On PostgreSQL and SQL Server, use your DBA's procedure (`pg_dump`, a native backup). See [Backup](./backup.md). Then migrate: ```bash hortval migrate -f /etc/hortval/config.yml --confirm ``` `--confirm` is your acknowledgement that a backup exists. It is required only for a change a restart may not apply. `hortval backup create` keeps working while startup is refusing — it is the remedy the refusal points at, so it has to work on a database Hortval will not start on, and it never changes the schema. ## `hortval migrate` ```bash hortval migrate [-f ] [--confirm] [--sql] ``` | Flag | Effect | |---|---| | `--confirm` | Acknowledges a backup. Required only for a breaking migration. | | `--sql` | Writes the SQL to standard output and applies **nothing**. | | Exit | Meaning | |---|---| | `0` | Applied, or nothing to do | | `1` | Failed — the schema may be partially migrated | | `2` | Bad invocation | | `3` | Refused: a breaking migration is pending and `--confirm` was not given | Exit `3` is distinct so a deployment script can tell a failure from a missing confirmation. Each migration runs in its own transaction: an interrupted run leaves a coherent prefix and re-running resumes where it stopped. **`--sql`** is for reviewing an upgrade, or handing it to a DBA: ```bash hortval migrate -f /etc/hortval/config.yml --sql > schema.sql ``` Progress goes to standard error, so the file runs as-is. Apply it **whole** — it carries the bookkeeping rows Hortval reads to know what has run. Statements are numbered in comments, so a client's "error at statement 12" points somewhere. ## When startup refuses | Situation | What to do | |---|---| | A breaking migration is pending | back up, then `hortval migrate --confirm` | | The database is **newer** than the binary | run a newer Hortval, or restore a backup taken before the upgrade | | Ahead on one module, behind on another — a failed upgrade followed by a rollback | restore, or move to the latest release, which carries every version involved | | A migration that already ran no longer matches this binary | run the build that produced this database, or restore a backup | A fresh install is exempt: creating a schema from nothing has nothing to gate. ## Databases where Hortval may not issue DDL For accounts that hold no schema rights: ```yaml database: driver: sqlserver dsn: "sqlserver://hortval:secret@sqlserver01:1433?database=hortval" noddl: true ``` Hortval then emits no DDL. It checks the schema at startup and refuses to run if anything is missing; `hortval migrate` writes the script for your DBA instead of applying it. The account still needs to **read** the catalog. That is what distinguishes "the schema is missing" from "I am not allowed to look" — without it you would be told to rebuild a schema that already exists. ## Choosing the schema Hortval writes to the schema its database account resolves to, and says which one at every start: ``` Database schema in use schema=public ``` Two instances pointed at the same database **and the same schema** share their data. That is a valid multi-node deployment, and an accident that looks identical from the database's side. For two separate installations, give each one a schema: ```yaml # PostgreSQL — the default search path sends every installation to `public` database: driver: postgres dsn: "postgres://hortval:secret@db01:5432/shared?options=-csearch_path%3Dhortval" ``` On SQL Server the schema comes from the database user, not the connection string: ```sql ALTER USER hortval WITH DEFAULT_SCHEMA = hortval; ``` Hortval also names, at start-up, any of its tables that are not in the schema it writes to — possible on a database migrated by a much older release inside a shared schema. The message gives both possible causes, because their remedies are opposite: moving the tables is right when they are an earlier install of *this* instance, and takes another application's tables away when they are not. ## If a migration fails Hortval logs the failing module and statement, then stops; the schema may be partially migrated. Restore the backup taken before the run, or fix the cause and run `hortval migrate` again — what already applied is skipped. See [Schema Reference](./schema.md) for what the tables hold. --- # administration/schema.md --- sidebar_position: 2 title: Database Schema --- # Database Schema Hortval's schema travels inside the binary. A restart applies additive changes on its own; anything heavier waits for an explicit [`hortval migrate`](./migrations.md), and on a database where Hortval may not issue DDL it writes the script for your DBA. This page is a **functional map** of the core ACME tables and how they change as an order progresses; it is not an exhaustive column-level reference. Internal/support tables (job queue, licensing, PKI request tracking, audit state) are managed automatically and are not covered here. ## ACME Flow Overview | Step | Endpoint | Server Action | Tables Affected | |------|----------|---------------|-----------------| | New Account | `POST /acme/newAccount` | Creates a client account | `acme_accounts` | | New Order | `POST /acme/newOrder` | Creates an order with authorizations and challenges | `acme_orders`, `acme_authorizations`, `acme_challenges` | | Challenge Validation | `POST /acme/chall/` | Client responds, server validates asynchronously | `acme_challenges`, `acme_authorizations` | | Finalization | `POST /acme/finalize/` | Client sends CSR → certificate issued | `acme_orders`, `acme_certificates` | | Certificate Retrieval | `GET /acme/cert/` | Client downloads certificate | Read only | | Revocation | `POST /acme/revoke-cert` | Revokes a certificate | `acme_certificates` | | Replay Protection | Automatic | Anti-replay nonces | `acme_nonces` | | Auditing | Automatic | All significant actions | Tamper-evident [audit log](./audit.md) (JSONL, separate from the database) | --- ## Table: `acme_accounts` Stores registered ACME client accounts (RFC 8555 §7.1.2). | Column | Description | |---|---| | `id` | Account ID (e.g. `/acme/acct/123`) | | `jwk` | Client public key in canonical JSON | | `contact` | List of `mailto:` addresses | | `status` | `valid`, `deactivated`, `revoked` | | `tos_agreed_at` | Timestamp of Terms of Service acceptance | **Created**: `POST /acme/newAccount` (when JWK thumbprint is new) **State transitions**: - `valid` → `deactivated`: via `POST /acme/acct/{id}` with `"status":"deactivated"` - `valid` → `revoked`: on full account revocation --- ## Table: `acme_nonces` Anti-replay nonces used in JWS request headers. | Column | Description | |---|---| | `nonce` | Random nonce value | | `issued_at` | When the nonce was issued | | `used_at` | When the nonce was consumed (null if unused) | Every ACME response generates a new nonce. Once a nonce is used in a valid JWS request, it is marked used and cannot be reused. Expired nonces are cleaned up periodically. --- ## Table: `acme_orders` Represents a certificate order. | Column | Description | |---|---| | `id` | Order identifier | | `account_id` | Owning account | | `status` | `pending`, `ready`, `processing`, `valid`, `invalid` | | `csr` | Raw CSR bytes / DER (set at finalize) | | `not_before`, `not_after` | Requested validity window | | `expires_at` | Order expiry | | `certificate_id` | Linked certificate (set when issued) | **Created**: `POST /acme/newOrder` **Status flow**: ``` pending → ready (all authorizations valid) → processing (finalize received) → valid (certificate issued) → invalid (challenge or issuance failure) ``` --- ## Table: `acme_authorizations` Proof of control for each identifier in an order. | Column | Description | |---|---| | `id` | Authorization ID (appears in its public URL) | | `identifier_value` | DNS name (e.g. `app.corp.internal`) | | `status` | `pending`, `valid`, `invalid` | | `expires_at` | Authorization expiry | | `wildcard` | Whether this is a wildcard authorization | | `error_msg` | Validation error detail (if failed) | When at least one challenge for an authorization becomes `valid`, the authorization becomes `valid`. When all authorizations for an order are `valid`, the order status moves to `ready`. --- ## Table: `acme_challenges` Validation challenges (DNS-01, HTTP-01, TLS-ALPN-01) for each authorization. | Column | Description | |---|---| | `id` | Challenge identifier | | `challenge_type` | `dns-01`, `http-01`, `tls-alpn-01` | | `status` | `pending`, `processing`, `valid`, `invalid` | | `token` | Challenge token | | `key_authorization` | Computed key authorization | | `validated_at` | Timestamp of successful validation | **Created**: automatically with `newOrder` **Updated**: on `POST /acme/challenge/` → moves to `processing`, then `valid` or `invalid` --- ## Table: `acme_certificates` Issued TLS certificates. | Column | Description | |---|---| | `id` | Certificate identifier | | `account_id` | Owning account | | `order_id` | Originating order | | `pem_chain` | PEM certificate chain (never includes private key) | | `not_before`, `not_after` | Validity window | | `fingerprint` | SHA-256 fingerprint of the leaf certificate | | `revoked_at` | Revocation timestamp (null if active) | | `revoke_reason` | RFC 5280 reason code (0–10, excluding 7) | **Created**: `POST /acme/finalize/` after successful ADCS issuance **Updated**: `POST /acme/revoke-cert` --- ## Full Flow Reference ``` POST /acme/newAccount → INSERT acme_accounts POST /acme/newOrder → INSERT acme_orders → INSERT acme_authorizations (one per identifier) → INSERT acme_challenges (one per auth × challenge type) POST /acme/challenge/{id} → UPDATE acme_challenges (status → processing) [async job validates DNS/HTTP/TLS] → UPDATE acme_challenges (status → valid/invalid) → UPDATE acme_authorizations (status → valid if one challenge valid) → UPDATE acme_orders (status → ready if all authorizations valid) POST /acme/finalize/{id} → UPDATE acme_orders (status → processing) [async job submits CSR to ADCS] → INSERT acme_certificates → UPDATE acme_orders (status → valid, certificate_id → ...) GET /acme/cert/{id} → SELECT acme_certificates POST /acme/revoke-cert → UPDATE acme_certificates (revoked_at, revoke_reason) ``` --- # administration/shutdown.md --- sidebar_position: 5 title: Graceful shutdown --- # Graceful shutdown Hortval stops cleanly on `SIGTERM` (Linux) and on **Ctrl+C / Ctrl+Break in a console** (Windows). This page describes the behaviour you can rely on, the two timeouts that bound it, and how to tune them. :::warning On Windows, a service stop does **not** drain — v0.9.5 Everything below applies when Hortval is stopped by a signal it actually receives. **The Windows Service Control Manager is not one of them in v0.9.5**: the SCM handshake is not implemented, so the process is killed after about thirty seconds with the drain unfinished, leaving `db.sqlite-wal` / `db.sqlite-shm` behind. Ship as a console process or under a wrapper until **v0.9.6**, which brings native service support. See [Installation → Windows service](../getting-started/installation.md#windows-service). ::: ## What happens on stop signal - The HTTP listener stops accepting new connections. - In-flight HTTP requests are allowed to finish. - Async work already claimed by the jobs engine (challenge validation, PKI polling, ADCS calls) is allowed to finish so its result is persisted. - The audit log and the database stay open for the full drain so late writes are never lost. - Once the drain is complete, the process exits. If a handler or a job is still running when the timeouts below expire, the process exits anyway and that work is interrupted. Jobs that were running at that point are picked up on the next start (the jobs queue is durable). ## The two timeouts | Setting | Default | What it bounds | |---|---|---| | `server.shutdown-timeout` | `30s` | How long Hortval waits for in-flight HTTP requests to finish before forcing the listener to close. | | `workers.drain-timeout` | `30s` | How long Hortval waits for in-flight async jobs to finish before forcing them to stop. | ### Invariant `server.shutdown-timeout` must be **less than or equal to** `workers.drain-timeout`. Hortval refuses to start otherwise: ``` server.shutdown-timeout (45s) must be ≤ workers.drain-timeout (30s): in-flight HTTP handlers can enqueue jobs after the engine has stopped draining ``` If HTTP outlasts the jobs engine, late requests can produce jobs that nobody runs until the next start. Keeping the HTTP timeout at most equal to the jobs timeout closes that window. The defaults (`30s` / `30s`) already satisfy this. ## Configuration ```yaml server: url: - https://acme.example.com listen: 0.0.0.0:8443 shutdown-timeout: 30s workers: workers: 16 drain-timeout: 30s ``` Both fields accept Go duration syntax (`s`, `m`, `h`). ## Tuning - **Slow PKI backend (ADCS enrollment latency).** Raise both timeouts together (e.g. `60s` / `60s`) so a final issuance has time to complete before the engine is forced down. - **Fast rotation, ephemeral instances.** The defaults are appropriate; don't lower them below `10s` or you will routinely interrupt healthy work. - **Stay below your service supervisor's own stop timeout.** Both systemd (`TimeoutStopSec`, default `90s`) and the Windows Service Control Manager send a hard kill after their own deadline. Keep `shutdown-timeout` and `drain-timeout` comfortably below it. ## In-flight ACME requests A long ACME operation (challenge validation, PKI poll) is allowed to keep running for the full `shutdown-timeout`. This is intentional — interrupting mid-request would leave the order in an awkward state for the client. If you need a hard ceiling on individual request duration, use `server.write-timeout` (default `30s`). ## After a restart Run `hortval audit verify` if the audit log is enabled. The audit chain is designed to resume cleanly across stop/start, but `verify` confirms that no gap was introduced and reports the first break otherwise. Jobs that were still running when the previous instance stopped are picked up automatically once their lease expires (default `30s`). Nothing manual is required. --- # changelog/index.md --- sidebar_position: 1 title: Changelog --- # Changelog ## v0.9.5 - unreleased **The first Hortval release.** One idea runs through it: the server no longer guesses where anything is. Every path it uses is one you wrote, ambiguity is refused instead of resolved silently, and each refusal names what it wanted and prints the exact line to paste. If you are coming from Certeasy v0.9.4 and you pass `-f` with absolute paths, your configuration needs nothing — jump to [Upgrading](#upgrading-from-v094--what-changes-for-an-existing-configuration) for the case-by-case. ### What this release gives you - **Hortval, under its own name.** The command, the binary and the release artefacts are `hortval` / `hortval---`. Every invocation changes with it: the `ExecStart` of a systemd unit, the `binPath=` of a Windows service, your runbooks and your monitoring. Nothing inside the ACME protocol carries the name, so **ACME clients are unaffected** — no account, order or certificate needs anything done to it, and no client configuration changes. The configuration file keeps its name and every one of its keys. - **Nothing is guessed any more.** Seven lookups and settings that used to resolve silently now refuse, and say what to write instead. This is the release, not a side effect of it — see [Breaking changes](#breaking-changes) for the list and [Upgrading](#upgrading-from-v094--what-changes-for-an-existing-configuration) for what to do. - **The Windows binary is signed with Authenticode**, timestamped so the signature outlives the signing certificate. Verify with `Get-AuthenticodeSignature`; the signer is SAFE PIC TECHNOLOGIES. Note that **SmartScreen still appears** — what changes is that it now names the publisher instead of saying "Unknown publisher". Linux and macOS binaries remain covered by `SHA256SUMS` for integrity only. See [Verifying release binaries](../security/verifying-binaries.md). - **SQL Server integrated authentication.** Add `authenticator=winsspi` to your `database.dsn` and drop the credentials: Hortval connects as the Windows account it runs under, and no SQL password sits in `config.yml`. Windows only, and it needs a domain account. **Hortval reports which method it obtained at startup** and warns when the connection falls back to NTLM — which happens silently, and succeeds, when no Service Principal Name is registered on the SQL Server service account. See [Database](../configuration/database.md). - **`hortval validate` now checks the filesystem**, and `--no-disk` skips it. It looks at what it can — does a working directory's parent exist, is an installation already sitting where a path resolves — because the machine you validate on is usually the machine that will serve. Two things it deliberately does not do: it never writes anything, and it never checks writability. A filesystem failure it cannot attribute is reported as a **warning**, not a verdict, because `validate` runs as the account that typed it while the server runs as its service account — guessing another identity's permissions produces confident wrong answers. - **New `%CONFIGDIR%` token, valid in `workdir`**, expanding to the directory holding the configuration file. It exists because `workdir` is the one path that could not use `%WORKDIR%` — it *is* the working directory. `hortval init` now proposes `%CONFIGDIR%/workdir` instead of `./workdir`, so a generated configuration means the same directory wherever it is later started from. - **A `letsencrypt` bundle now always serves an ECDSA P-256 certificate.** Previously the key type was decided by whichever client connected first, and then served to everyone: `autocert` indexes its cache by key type and holds both, but the certificate manager keeps one certificate per bundle, so the first handshake settled it for the life of the process. If a client of yours needs RSA, use `mode: pki` or `mode: files`, where `key.type` is yours to set. ### Not yet: running as a Windows service — this ships in v0.9.6 **v0.9.5 cannot be started by the Windows Service Control Manager.** The SCM handshake is not implemented: `sc.exe start` fails with error 1053, and the process is killed after about thirty seconds with its shutdown drain unfinished, leaving `db.sqlite-wal` / `db.sqlite-shm` behind. Run it in a console, or under a wrapper that performs the handshake on its behalf (a scheduled task, or NSSM — neither is validated against Hortval yet). Native service support, the Windows event log, and a `hortval diag` subcommand to prove where the messages went are the headline of **v0.9.6**. See [Installation → Windows service](../getting-started/installation.md#windows-service). This is stated here rather than left to be discovered: on the platform Hortval targets, "runs as a service" is what deployment means. ### Breaking changes All of them are the same rule — **a value that was guessed is now demanded** — and each refusal prints what to write. Only the last row is a rename rather than a refusal. | What | v0.9.4 did | v0.9.5 does | |---|---|---| | `workdir` absent | picked a location for you | **refuses.** Why, and the conventional value per platform: [`workdir`](../configuration/overview.md#workdir) | | a path setting holding a relative path | resolved it against the current directory | **refuses** unless absolute or anchored with `%WORKDIR%` / `%CONFIGDIR%` | | `config.yml` in the current directory | loaded it, ahead of everything else | **not searched at all** — the current directory of a service created by `sc.exe` is `C:\Windows\System32`, and any directory a lesser account can write to selected the database, the working directory, the audit destination and the outbound proxy | | two candidate configuration files | loaded one, ignored the other | **refuses**, naming both | | a configuration file that exists but cannot be read | selected it, then failed to open it | **skipped and reported**; a readable one is used with a warning | | `%WORKDIR%` in `audit.path`, `logs.file`, `letsencrypt.cache-dir`, `local-cert-file`, `local-key-file` | taken literally — it created a directory actually *named* `%WORKDIR%` | **resolved** | | an unrecognised name in `logs.services` | silently ignored, so the level you asked for never applied | **refuses**. `Certeasy-acme-server` → `acme-server`, `cert-easy-main` → `main` | Two lookups also changed order without changing any outcome, since ambiguity is now refused either way: configuration is looked for under `hortval` before `certeasy` (the `certeasy` directories are still read, with a warning naming both; they go away in v2), and machine-wide directories are searched before per-user ones on every platform. **The licensing backend moved to `api.hortval.com`.** If your egress is filtered by hostname, allow it before upgrading — otherwise online licence checks fail and the deployment slides into its degraded states. Offline installations are unaffected. ### Upgrading from v0.9.4 — what changes for an existing configuration Nothing to do if you pass `-f` and every path in your configuration is absolute. Otherwise, find your case: | Your v0.9.4 setup | v0.9.4 did | v0.9.5 does | What to do | |---|---|---|---| | `-f ` on the command line | loads it | same | nothing | | No `-f`, `config.yml` in the **current directory** | loaded it | **refuses**, and lists where it looked | pass `-f`, or move the file | | No `-f`, `config.yml` next to the binary | loaded it | same | nothing | | No `-f`, `config.yml` in `/etc/certeasy` or `%PROGRAMDATA%\certeasy` | loaded it | loads it **and warns** | rename the directory to `hortval` before v2 | | Two candidate files in two directories | loaded the first, ignored the other | **refuses**, names both | pass `-f`, or delete one | | `config.yml` **and** `config.yaml` side by side | `.yml` won | **refuses**, names both | pass `-f`, or delete one | | A config file that exists but cannot be read | was selected, then failed to open | **skipped**, and reported; another readable file is used with a warning | fix the permissions | | `workdir: ./workdir` (or any relative path) | resolved against the working directory | **refuses**; if a live installation sits where it resolves, prints the exact line to write | use that line, or an absolute path, or `%CONFIGDIR%/…` | | `workdir` not set, old directory holds data | `%ProgramData%\certeasy` / `/var/lib/certeasy` | **refuses**, and names the three ways out | move it, or name it with `workdir:`, or delete it if it is a leftover | | `workdir` not set, nothing in the old directory | `%ProgramData%\certeasy` / `/var/lib/certeasy` | `%ProgramData%\hortval` / `/var/lib/hortval` | nothing | | `workdir` set to an absolute path | resolved | same, and the rename check is skipped | nothing | | `database.path: %WORKDIR%/db.sqlite` | resolved | same | nothing | | `audit.path`, `logs.file`, `letsencrypt.cache-dir`, `local-cert-file`, `local-key-file` with `%WORKDIR%` | **taken literally** — created a directory actually named `%WORKDIR%` under the working directory | resolved | delete any stray `%WORKDIR%` directory once the real files are in place | | `certreq-path` / `certutil-path` | resolved from the Windows system directory, relative refused | **unchanged** | nothing | | `logs.services` naming a service | applied it | **refuses** an unrecognised name (they were silently ignored); `Certeasy-acme-server` → `acme-server`, `cert-easy-main` → `main` | rename the keys | The wizard is affected too: `certeasy init` used to write `workdir: ./workdir`, which v0.9.5 refuses. A configuration generated by v0.9.4's wizard therefore needs its `workdir` line changed — the startup message tells you what to write. See [Minimal configuration](../getting-started/minimal-configuration.md) for the full search order, and [Configuration overview](../configuration/overview.md) for the anchor tokens. If you are coming from a release older than v0.9.4, work through [Upgrading](../upgrading.md) first — it collects everything that changed since v0.9.1. ### Rolling back to v0.9.4 **You can.** Nothing in this release prevents going back: - A configuration fixed for v0.9.5 stays readable by v0.9.4, provided you used the absolute paths the startup message hands you. The `%CONFIGDIR%` token is new and v0.9.4 would take it literally — it only appears in configurations generated by `hortval init`, that is on fresh installs, which have nothing to roll back to. - No schema migration ships in this release, so the database is unchanged. Keep this in mind if you edit by hand: anchoring a path with a token that v0.9.4 does not substitute is what would close the door. An absolute path never does. ## v0.9.4 - 2026-08-04 We re-review the Certeasy codebase internally whenever materially more capable code-analysis tooling becomes available. This release is the outcome of such a review, run in July 2026 with the then-current generation of Claude and triaged by hand. Every finding was assessed, and the ones that could affect a running deployment are fixed here. Two breaking changes had to be introduced in the product's configuration along the way; both are described below. The review also recorded what was attacked and held. That part never appears in a changelog, which is a shame, because it is what a security review is actually for: - **No finding permits private key compromise, customer data exfiltration, or remote code execution.** - **CSR validation verifies the signature** rather than assuming proof of possession, and parses the request **twice** — once with the standard library, once with a strict hand-written ASN.1 pass — then compares the resulting SAN sets byte for byte, as a defence against parser differentials. Subject Alternative Names are restricted to `dNSName`: `otherName`/UPN, `rfc822Name`, URI and IP addresses are refused outright. This makes the ESC1/ESC6 escalation vector **structurally impossible to request rather than filtered** — a distinction that matters on an ADCS deployment. It bounds what a client can ask for, not what your CA can issue; the template remains yours to harden. See [Certificate Security Model](../security/certificate-model.md). - **JWS algorithm confusion has no landing point.** The verifier is selected on key type, each verifier re-reads the protected header and requires an exact algorithm with a matching curve, and there is **no HMAC verifier and no `none` verifier anywhere in the code** — so the classic "sign with HS256 using the public key as the secret" attack has nothing to reach. - **No IDOR.** Every endpoint that takes an object identifier — account, order, finalize, authorization, challenge, certificate — compares the object's owning account against the authenticated one before returning anything. No handler fetches by identifier without that check. The single unscoped lookup in the codebase is reachable only on the certificate-key revocation path — which RFC 8555 §7.6 requires — and only after the embedded key has been matched byte for byte against the certificate's public key. - **No SQL injection.** Every query is a compile-time constant with placeholders; there is no `LIKE` query anywhere in the repository and every `ORDER BY` is a literal. A dedicated security page covering the threat model and host hardening is in preparation. ### Security This release resolves findings from an internal security review. Details of the underlying mechanisms are deliberately omitted while deployments upgrade. - **A specially crafted request could cause a denial of service** (remote, unauthenticated). Fixed. No data exposure and no authentication bypass. - **Order identifiers are now canonicalised when the order is created**, and non-DNS identifier types are rejected explicitly. Previously an identifier was stored as submitted, so what was validated could differ from what was stored. This is a conformance fix (RFC 8555 §8.4) rather than an exploitable one: reaching the divergent case required write access to the DNS zone, which already allows obtaining the same certificate through the normal `dns-01` path. It is corrected because a stored value that diverges from the validated one is a hazard for future changes, not because it granted anything. - **Valid requests could be rejected under normal operation.** A defect in the replay-protection bookkeeping refused nonces that were still valid. **Any deployment with two or more concurrent ACME clients was affected continuously**, without anyone attacking it — clients recovered by retrying as the protocol requires, so the symptom was extra round-trips and latency rather than visible failures. Single-client deployments were never affected. - **Log and audit files are now owner-only** (`0600`, directories `0700`), matching the rest of the product. This matters as soon as `audit.path` points outside the working directory: the audit trail records account identifiers, source IPs, user agents and decisions. **On Windows this has no effect**: access there is governed by the NTFS permissions inherited from the containing folder — set that folder's ACL at install time. The mode bits apply on Linux and macOS. - **Audit log rotation could destroy history.** A failed rotation — an antivirus holding the file, a full disk, a permissions problem — could discard a generation of history without archiving anything, and without reporting it. Rotation was rewritten: it now fails without touching existing files, reports the reason on stderr, and retries later. See *Breaking changes* for the consequence on file naming. - Updated `golang.org/x/text` and `golang.org/x/crypto`. ### Rate limiting Two changes here, and the second is new behaviour rather than a new setting. - **The per-IP request ceiling now applies to every endpoint**, and is checked before any cryptographic work. Previously only four endpoints consulted it; the rest — including the polling a client does while waiting for validation — were unmetered. Because the ceiling now sees a client's full traffic, its defaults were raised accordingly: `requests-per-minute` from `200` to `1200`, `burst` from `20` to `100`. If you had tuned these values down, review them: issuing a single 3-name certificate costs at least 18 requests. - **New `abuse` limiter, enabled by default.** It does not cap requests; it *marks* an IP that behaves in a way no conformant client does — a signature that does not verify, or an attempt on a resource belonging to another account. A marked IP is then refused on everything until it recovers, which takes about thirty seconds of good behaviour. Ten such events are tolerated first, so a client that slips once is unaffected. Marks are **weighted**. A request for an identifier that simply does not exist counts a quarter, because it has an innocent reading — a client returning to a URL whose resource has since been cleaned up gets the same answer as someone probing at random. Four such misses make one abuse, so occasional ones cost nothing while systematic probing still blocks. Routine outcomes count nothing at all, `badNonce` in particular: it happens to every client whenever the server restarts. See the configuration reference for the full table. - **Account creation now allows a deployment wave.** `account-creation` moves from `5`/hour with a burst of `2` to `10`/hour with a burst of `10`. Creating an account is a once-per-machine-for-life event, so legitimate traffic arrives in bursts when you provision, not at a steady rate — and a burst smaller than the hourly allowance refused the third machine of a batch deployed behind a single NAT address. The sustained ceiling, which is what actually bounds abuse, only doubles. If your clients reach Certeasy through a reverse proxy, set `trusted-proxies` in the `server` section so the limiters see real client addresses. Whitelisting the proxy would exempt every client behind it. ### Schema and migrations A restart is rarely something a human decided — a crash, a reboot, a service manager or a failed health check all restart the process, and none of them is a moment when someone is standing by with a backup. Certeasy now applies on its own only what doing nothing could have survived. - **A restart applies additive migrations only.** Anything that cannot be undone by rolling the binary back stops startup and names what is pending. Nothing changes today for an existing deployment: every migration currently shipped is additive. - **New command `certeasy migrate`**, with `--confirm` to acknowledge a backup and `--sql` to write the statements instead of running them. - **New setting `database.noddl`** for accounts that hold no schema rights. - **A database newer than the binary, or left mid-upgrade, is refused** rather than started on. See [Migrations](../administration/migrations.md) for the full contract, the exit codes and the `noddl` workflow. ### Breaking changes - **The `adcs-cli` connector takes `certreq.exe` and `certutil.exe` from the Windows system directory** (typically `C:\Windows\System32`) instead of looking them up through `%PATH%`. An unset key, or a bare file name with or without the `.exe` extension, resolves there — existing configurations keep working. Set a full path to run a copy kept elsewhere, for instance on a path carved out of an EDR policy. A path relative to the working directory (`tools\certutil.exe`) is now refused at startup and by `certeasy validate`. The native connector is unaffected: it runs in-process over COM and starts no external binary. - **ADCS `ca-name` and `certificate-template` are refused when they contain a control character or a colon, or start with a dash.** Those characters delimit fields in the request sent to the CA, so a value carrying one changes the request rather than naming it. Ordinary names — `PKI01\Lab-Issuing-CA`, `Web Server v2` — are unaffected. - **A `dns-validation-profiles` entry with neither `allow-cidrs` nor `deny-cidrs` is refused at startup**, and by `certeasy validate`. An empty `resolved-ip-policy` accepted every address DNS returned — link-local and cloud metadata (`169.254.169.254`) included. Unset is not the same as deliberately open, so it is refused instead of defaulted. Add the networks your targets live on, or `allow-cidrs: ["0.0.0.0/0", "::/0"]` if you accept any address on purpose. This applies to every profile whatever challenge you use: challenge types cannot be restricted per profile, so a profile that has only ever seen `dns-01` still offers the paths this policy guards. Configurations produced by `certeasy init`, and the shipped examples, already carry the block. - **A `fake` authority with no `common-name`, no `password`, a `key-size` below 2048 or a non-positive `validity` is refused at startup**, and by `certeasy validate`. That driver parsed its configuration without checking any field, so these were accepted and then failed later — after the database had been migrated — or produced a CA that expired the second it was created. - **`audit.rotate.max-backups` no longer exists and a configuration containing it is refused at startup.** The audit log is a compliance artifact: deleting it by file count is not a setting. Remove the key from your configuration before upgrading. Retention will return as a *duration*, which is the unit a compliance requirement is actually written in. - **External rotation of the audit file (logrotate, Scheduled Task) is no longer supported.** A third party renaming or truncating the file breaks the tamper-evident chain, and Certeasy cannot distinguish that from tampering. Certeasy segments the file itself; remove any logrotate rule targeting `audit.path`. Earlier documentation recommended `copytruncate` here — that recommendation was wrong and is withdrawn. - **With rotation enabled, `logs.file` and `audit.path` are naming bases, not files.** Segments are written beside them as `...` — for example `certeasy.20260726T091702Z.ms123.log` — and are **never renamed**. Configure log collectors with the containing folder and a `*.log` pattern rather than the path in `file`; following the live file interactively now means picking the newest segment. This also means existing `logrotate` rules on `logs.file` become inert. Rationale: a rotation now creates exactly one file and mutates nothing else, and a closed segment is immutable — which is what makes archival, and the audit chain, safe. It also removes a long-standing annoyance for log collectors, which previously saw duplicate or missing lines at every rotation. ### Fixes - **On a shared database, Certeasy could build its schema in the wrong place.** Where a table of the same name already existed in another schema — a `jobs` or a `servers` belonging to a different application — Certeasy could take it for its own and then read and write that application's data. SQL Server deployments using a schema other than `dbo` were affected. Certeasy no longer asks the question that made this possible. - **The schema in use is reported at every start**, and any of Certeasy's tables found outside it are named. Two instances sharing a database *and* a schema share their data — a valid multi-node deployment, and an accident that looks identical from the database's side. See [Migrations](../administration/migrations.md). ### Documentation - **The migrations page was rewritten** — it described a schema silently brought up to date at every start, with nothing ever to run by hand. Neither is true any more. - The database configuration page gained the `noddl` setting and a section on schema selection. - The rate-limiting page described the whitelist incorrectly: it stated that `order-creation` still applied to a whitelisted IP. It does not — a whitelisted IP also bypasses that limiter. The behaviour is unchanged; only the documentation was wrong. The limits that remain in force for a whitelisted IP are the account-scoped ones: `duplicate-certificate`, `failed-validation` and `pending-authorizations`. ### Improvements - **Security controls that are switched off are now announced at startup.** Disabling the audit log or a rate limiter is a legitimate choice; doing it without a trace is not. Certeasy logs one warning per disabled control, naming the setting and its consequence. A default configuration stays silent — a warning that fires on a healthy install is noise. --- ## v0.9.3 - 2026-07-09 ### New features - **ADCS certificate revocation**: ACME revocations now propagate to the backing Microsoft ADCS CA (CRL / OCSP), not just Certeasy's own database. RFC 8555 §7.6 authorization is supported with both the account key (`kid`) and the certificate key (`jwk`). Propagation can be turned off per authority with `disable-ca-revocation: true` (for service accounts without the required CA role, or air-gapped deployments). Note: revoking on the CA needs the **Certificate Manager** role — a higher privilege than enrollment. - **`certeasy adcs check`** — a read-only preflight for ADCS authorities: checks that the CA is reachable, that the certificate template is published, and reports the template's key requirement. Use it to diagnose ADCS setup before starting the server (or to hand support a clear status). - **Configurable server-certificate key**: a `pki`-mode bundle in the TLS certificate manager now accepts an explicit `key:` — `type: rsa` with `size:` (bits), or `type: ecdsa` with `curve:` (`P-256`/`P-384`/`P-521`). Set it when the CA template mandates a specific key. In particular, an ADCS template that requires **RSA 4096** previously rejected Certeasy's default ECDSA key and prevented startup; setting `key.type: rsa` with `key.size: 4096` resolves it. The default is unchanged (ECDSA P-256). ### Improvements - **`certeasy init` — ADCS onboarding**: the wizard now defaults to ADCS, lists the CA's published certificate templates so you select the exact one (no typos, only published templates), and reads the template's key requirement to set the server-certificate key automatically — falling back to asking you when the template cannot be read. - **Clearer ADCS denial messages**: when the CA denies a request, Certeasy now surfaces the actual reason (for example, "the public key does not meet the template's key size requirement", `CERTSRV_E_KEY_LENGTH`) with an actionable hint, instead of an opaque `CR_DISP_DENIED`. The diagnosis is keyed on the CA's error code, so it stays accurate regardless of the CA's display language. ### Security - Updated the Go toolchain to **1.26.5** to address **GO-2026-5856** (an Encrypted Client Hello privacy leak in the standard library's `crypto/tls`). --- ## v0.9.2 - 2026-06-17 ### New features - **Native ADCS connector** (now the default for `type: adcs`): Certeasy enrolls against ADCS **in-process**, without launching `certreq.exe`. This removes the child-process (LOLBin) signature that strict EDRs flag, making Certeasy eligible for more hardened deployment perimeters. Existing `type: adcs` configurations switch to it automatically on upgrade — no change required. - **`certeasy validate`** — a configuration check in the spirit of `nginx -t`. `certeasy validate -f ` parses and validates a configuration statically, with no side effects (no database, no network, no file writes). `serve` now runs the same validation as a fail-fast boot gate, so an invalid configuration is rejected before startup instead of failing halfway through. ### Improvements - `certeasy init` now lets you choose the ADCS connector (native in-process, or `certreq.exe`) when generating a configuration. ### Changes - The `certreq.exe`-based integration remains available as an opt-in fallback under `type: adcs-cli`. - The unused `cert-util-timeout` ADCS option is no longer documented; it is still accepted in existing configurations but has no effect. - **ADCS request timeout**: `default-timeout` now defaults to **4 minutes** and is honored as configured. Previously a legacy behavior capped the effective ADCS wait at **30 seconds** regardless of `default-timeout`; that cap is removed. Keep `default-timeout` below `workers.max-job-duration` (default 5m) — Certeasy warns at startup if it is greater than or equal. ### Fixes - `certeasy init` generated an ADCS authority block with incorrect field names; it now emits the correct `ca-name` / `certificate-template` schema. - **SQL Server backends**: upgraded the SQL Server driver to go-mssqldb v1.10.0, which improves connection handling when a query is cancelled or times out. Recommended for all SQL Server deployments. - **DNS validation profiles**: the zone `protocol` field (`udp` / `tcp`) is now honored. It was previously parsed but ignored (DNS lookups were always UDP-first with a TCP fallback); set `protocol: tcp` to force DNS validation over TCP on networks where UDP/53 is unavailable. --- ## v0.9.1 - 2026-06-10 ### New features - `certeasy init` command — interactive (and scriptable) wizard to generate a `config.yml`. - Database connection: new `conn-max-lifetime` and `conn-max-idle-time` options. ### Improvements - Readable text output for one-shot subcommands (`license`, `backup`, `audit`, `cold-start`). The `serve` daemon keeps its structured JSON output. - All displayed and logged dates are in UTC. - Richer startup banner: installation ID and license ID. ### Changes - The `--grace` and `--cold-start-plan` flags are removed. The boot mode (valid license, post-expiration, cold-start, refusal) is now determined automatically from persistent state. - License registration is validated by the portal; a portal refusal is surfaced verbatim to the operator. - A license retired by the portal enters a grace window before boot is refused. ### Fixes - Shutdown stability: removed a possible SQLite panic and a goroutine leak on the ACME rate limiter. --- ## v0.9.0 - 2026-05-31 Initial public release. ### Features - ACME server (RFC 8555) covering account registration with key rollover, orders, authorizations, challenge validation, finalization, certificate retrieval, and revocation - HTTP-01, DNS-01 and TLS-ALPN-01 challenge validation - Wildcard certificates, including mixed `[apex, *.apex]` orders (RFC 8555 §7.1.4) - ACME Renewal Information endpoint (RFC 9773, read-only) for client-driven renewal scheduling - ADCS authority via `certreq.exe` - Built-in fake PKI authority for local testing - Issuance policies with DNS scope rules and signature constraints - Policy bindings with `first_available` and `round_robin` strategies - Server-side rate limiting per ACME account (duplicate-certificate) - SQLite (default), PostgreSQL and SQL Server backends - Async job engine with persistent retry and exponential backoff - TLS certificate manager for the server's own certificate (`files` and `pki` modes) - Structured logging with per-service level overrides and log rotation - Tamper-evident ACME audit log (JSONL + HMAC chain, validated by the `audit verify` command) - SQLite backup CLI (`backup create` / `backup verify`) - License enforcement with strict boot and acknowledgement of degraded states - Graceful HTTP shutdown - Built-in mitigations against ESC-class attacks: DNS-only identity, Server Authentication EKU only by default ### Interoperability covered by automated tests - ACME clients: certbot, lego, acme.sh, and a built-in protocol client - Backends: ADCS, fake PKI - Databases: SQLite, PostgreSQL, SQL Server - Full clients × challenges × databases × backends matrix --- :::note Certeasy v0.9.x is a **stable release**, used for day-to-day issuance, renewal and revocation. The full **production-ready** label is reserved for the upcoming **v1.0**, which closes the known, non-blocking limitations below: - **Certeasy cannot be started as a Windows service with `sc.exe`.** The Service Control Manager handshake is not implemented, so `sc.exe start` fails with error 1053 and the process is killed after about thirty seconds, leaving the shutdown drain unfinished. Run it in a console, or under a wrapper that performs the handshake. Planned for v1.0. See [Installation](../getting-started/installation.md). - **Some startup log lines only reach stderr, never `logs.file`.** The file receives everything the audit, PKI, HTTP, license and worker services emit, but a few lines from the main service are written before the configured destination is installed. A *refused* startup writes nothing to the file at all. Under systemd those lines land in journald; under the Windows SCM they are lost. Planned for v1.0. See [Logging](../administration/logging.md). - **No health or metrics HTTP endpoints yet.** Operational monitoring is limited to log scraping and database introspection for now; dedicated `/health` and metrics endpoints are planned for v1.0. - **No automatic data retention or cleanup.** ACME tables (orders, authorizations, challenges, …) grow without bound. Operators running long-lived deployments should plan for manual maintenance until automated retention ships in v1.0. - **RFC 9773 `replaces` field is accepted but not yet honored.** Clients can supply `replaces` on new orders without error, but the linkage to the previous certificate is not applied. The `renewalInfo` endpoint itself is fully functional; full `replaces` semantics are planned for v1.1. - **External Account Binding (EAB)** is not supported and is not planned for v1.0. Single-tenant enterprise deployments do not need it; see the [roadmap](../intro/roadmap.md) for v2.0 timing. - **Caddy** interoperability has not been formally validated in this release. ::: --- # clients/acme-sh.md --- sidebar_position: 2 title: acme.sh --- # acme.sh [acme.sh](https://github.com/acmesh-official/acme.sh) is a pure shell client — useful when Python (certbot) or Go (lego) aren't available, or simply when minimum runtime footprint matters. This page covers the acme.sh-specific bits; for the general onboarding flow and trust-store setup see [First Certificate](../getting-started/first-certificate.md). :::info Documented for acme.sh **3.x** (tested with the latest `neilpang/acme.sh:latest` Docker image) acme.sh's CLI has been stable for years — `--issue` / `--renew` / `--revoke` and their flags work identically across 2.x and 3.x. If you are on an older version and a flag is missing, upgrade is the recommended path. ::: ## What changes vs certbot - **Key type**: acme.sh generates **RSA 2048** by default. This is **refused** by Hortval under the default `signature.min-rsa-bits: 3072` policy — always pass `--keylength` explicitly: - `--keylength 3072` or `4096` for RSA - `--keylength ec-256` or `ec-384` for ECDSA - See [FAQ → RSA-only templates](../reference/faq.md#rsa-only-templates). - **CSR EKU — read this carefully**: acme.sh's built-in OpenSSL template declares **both `serverAuth` and `clientAuth`** in the CSR's Extended Key Usage, regardless of intended purpose. By default Hortval rejects this combination, returning `badCSR: EKU 1.3.6.1.5.5.7.3.2 in CSR not allowed by policy`. Two paths to make it work: 1. **Strict (preferred from June 2026)**: drop `clientAuth` from acme.sh's CSR template. The CA/B Forum baseline forbids the `serverAuth + clientAuth` combination on publicly-trusted server certificates from June 2026 onwards — production deployments should align even when fronted by an internal ADCS. The cleanest way is to maintain a private fork of `acme.sh` or to override its OpenSSL config file (`~/.acme.sh/openssl.cnf` if you use `--certhome`). 2. **Pragmatic (existing fleet)**: add `clientAuth` to the policy's `csr.allowed-extra-eku` on the Hortval side. See [Configuration → Issuance policies → EKU](../configuration/issuance-policies.md). This unblocks acme.sh as-is; plan a migration before June 2026. :::note Still true — verified 2026-08-21 Measured on a CSR produced by `neilpang/acme.sh:latest` in our ADCS lab. The request carries the extension verbatim: ``` 2.5.29.37 = 30 14 06 08 2b 06 01 05 05 07 03 01 06 08 2b 06 01 05 05 07 03 02 └ SEQUENCE ─ 1.3.6.1.5.5.7.3.1 (serverAuth) ─ …3.2 (clientAuth) ``` Worth re-checking when you upgrade acme.sh: the day its template drops `clientAuth`, `allowed-extra-eku` becomes dead configuration you can remove — and nothing will tell you, because a CSR asking for *less* than the policy allows is accepted in silence. **It asks, it does not receive, and it does not mind.** On the same run, the issued certificate came back with `Server Authentication` only, marked `Origin=Policy` — the ADCS template built the EKU and ignored the request. acme.sh completed normally. So allowing `clientAuth` here decides whether *Hortval* accepts the request, not what the certificate contains. See [Issuance policies → clientAuth and the CA/B Forum baseline](../configuration/issuance-policies.md#note-on-clientauth-and-the-cab-forum-baseline). ::: - **Trust store**: acme.sh uses curl under the hood. Point both `--ca-bundle` and the `CA_BUNDLE` / `CURL_CA_BUNDLE` env vars at your OS bundle: ```bash # Debian / Ubuntu export CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt export CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt # RHEL / CentOS / Rocky export CA_BUNDLE=/etc/pki/tls/certs/ca-bundle.crt export CURL_CA_BUNDLE=/etc/pki/tls/certs/ca-bundle.crt ``` `--ca-bundle` is for the ACME server's TLS cert; `CURL_CA_BUNDLE` covers the underlying curl invocations acme.sh uses for some HTTP calls. Setting both is belt-and-braces. - **Built-in scheduler**: acme.sh installs its own daily cron entry via `--install-cronjob`, no systemd timer to write. ## HTTP-01 (standalone) ```bash acme.sh --issue \ --server https://acme.corp.internal/acme/directory \ --ca-bundle /etc/ssl/certs/ca-certificates.crt \ --standalone \ --keylength 3072 \ -d app.corp.internal ``` Cert + key land under `~/.acme.sh/app.corp.internal/`: - `fullchain.cer` — leaf + chain. - `app.corp.internal.cer` — leaf only. - `app.corp.internal.key` — private key. - `ca.cer` — chain only. acme.sh's `--standalone` binds port 80 with a tiny built-in server for the duration of the challenge. ## HTTP-01 (webroot) ```bash acme.sh --issue \ --server https://acme.corp.internal/acme/directory \ --ca-bundle /etc/ssl/certs/ca-certificates.crt \ --webroot /var/www/html \ --keylength 3072 \ -d app.corp.internal ``` acme.sh writes the challenge token under `/var/www/html/.well-known/acme-challenge/`. The web server must serve that path over HTTP without authentication. ## TLS-ALPN-01 ```bash acme.sh --issue \ --server https://acme.corp.internal/acme/directory \ --ca-bundle /etc/ssl/certs/ca-certificates.crt \ --alpn \ --keylength 3072 \ -d app.corp.internal ``` Same constraint as HTTP-01 standalone but on port 443. ## DNS-01 (for wildcards) acme.sh has its own catalogue of DNS plugins under `~/.acme.sh/dnsapi/`. Configure with env vars and pass `--dns dns_`: :::warning Disable the public DNS pre-check on intranet deployments By default, acme.sh resolves the just-installed `_acme-challenge.` TXT record through public DoH resolvers (Cloudflare, Google) before notifying Hortval. On an internal-only deployment this check **will always fail** — the names do not exist publicly — and worse, the internal domain name is leaked in cleartext to the public resolvers. Pass `--dnssleep ` to skip the pre-check and wait `N` seconds before notifying the server. A short value such as `--dnssleep 1` is enough when the DNS plugin updates the authoritative server synchronously (`dns_nsupdate`, BIND/Knot RFC 2136, MS DNS via `dns_namesilo` etc.); larger values may be required for providers with slow API propagation. ::: ```bash # Example: RFC 2136 / nsupdate export NSUPDATE_SERVER='10.0.0.53' export NSUPDATE_KEY='/etc/acme.sh/keyfile' acme.sh --issue \ --server https://acme.corp.internal/acme/directory \ --ca-bundle /etc/ssl/certs/ca-certificates.crt \ --dns dns_nsupdate \ --dnssleep 1 \ --keylength 3072 \ -d '*.corp.internal' ``` The full list of plugins is in acme.sh's [DNS API documentation](https://github.com/acmesh-official/acme.sh/wiki/dnsapi). ## Renewal acme.sh installs its own daily cron entry. Enable it once after the first issuance: ```bash acme.sh --install-cronjob ``` The cron job runs `acme.sh --cron` daily; certs within 30 days of expiration are renewed automatically. To force a one-off renewal: ```bash acme.sh --renew -d app.corp.internal --force ``` ## Revocation ```bash acme.sh --revoke -d app.corp.internal \ --server https://acme.corp.internal/acme/directory \ --ca-bundle /etc/ssl/certs/ca-certificates.crt ``` --- # clients/certbot.md --- sidebar_position: 1 title: certbot --- # certbot [certbot](https://certbot.eff.org/) is the EFF's reference ACME client, Python-based, with the broadest community documentation and the most mature distro packaging. This page is the comprehensive reference; for a 5-minute onboarding tour see [Getting Started → First Certificate](../getting-started/first-certificate.md). :::info Documented for certbot **3.x** (tested with the latest `certbot/certbot:latest` Docker image) certbot's CLI has been stable since 1.x — the `certonly` / `renew` / `revoke` subcommands and their flags work identically across recent versions. If you are on an older 1.x or 2.x release, the syntax on this page still applies. ::: ## What changes vs lego / acme.sh - **Key type**: certbot's default depends on the version — certbot **2.x defaults to ECDSA** (P-256), older versions to **RSA 2048**. Neither satisfies a template (or policy) that mandates RSA 4096, so pass the key type explicitly when you need it: `--key-type rsa --rsa-key-size 4096` for RSA, or `--key-type ecdsa` for ECDSA. See [FAQ → RSA-only templates](../reference/faq.md#rsa-only-templates). - **CSR EKU**: certbot declares `serverAuth` only — no `clientAuth` smuggling, no need to loosen `csr.allowed-extra-eku` on the policy. - **Trust store**: certbot uses Python's own CA bundle (`certifi`), **not** the OS trust store. You point it at the OS bundle via the `REQUESTS_CA_BUNDLE` env var. Once set, the OS trust store becomes the single source of truth for both system commands and certbot. - **Built-in scheduler**: certbot installs a `certbot.timer` systemd unit on most distros. `systemctl enable --now certbot.timer` is enough to wire renewal. ## Trusting your internal CA Hortval's HTTPS certificate is signed by your internal ADCS root CA. ACME clients need to trust that CA, otherwise the TLS handshake fails before any certificate request can be made. The recommended approach is to **deploy your root CA to the OS trust store on all Linux servers** — ideally via your configuration management tool (Ansible, Puppet, Chef…). This is good practice regardless of Hortval: any internal service using TLS with an internal CA benefits from it. ```bash # Debian / Ubuntu sudo cp internal-root-ca.pem /usr/local/share/ca-certificates/internal-root-ca.crt sudo update-ca-certificates # → consolidated bundle at /etc/ssl/certs/ca-certificates.crt # RHEL / CentOS / Rocky sudo cp internal-root-ca.pem /etc/pki/ca-trust/source/anchors/internal-root-ca.pem sudo update-ca-trust # → consolidated bundle at /etc/pki/tls/certs/ca-bundle.crt ``` With Ansible, this becomes a one-liner across your fleet: ```yaml - name: Deploy internal root CA copy: src: internal-root-ca.pem dest: /usr/local/share/ca-certificates/internal-root-ca.crt # adjust for RHEL notify: update-ca-certificates ``` **certbot does not use the OS trust store directly** — it uses Python's own CA bundle (`certifi`). Once your CA is in the OS trust store, you can point certbot to the system bundle file with `REQUESTS_CA_BUNDLE`, so both stay in sync automatically: ```bash # Debian / Ubuntu export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt # RHEL / CentOS / Rocky export REQUESTS_CA_BUNDLE=/etc/pki/tls/certs/ca-bundle.crt ``` For automated renewal, set this in certbot's systemd service: ```ini # /etc/systemd/system/certbot.service.d/override.conf [Service] Environment="REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt" ``` This way there is a single source of truth: the OS trust store. Update it, and certbot picks up the change automatically. ### `--no-verify-ssl` (testing only) :::danger Do not use in production `--no-verify-ssl` disables TLS certificate verification entirely. The client has no guarantee it is talking to your Hortval instance — the connection could be intercepted. Acceptable for a quick local test, never for production or automated renewal. ::: ## HTTP-01 (standalone) The simplest approach: certbot spins up a temporary HTTP server on port 80 to answer the challenge. ```bash export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt certbot certonly \ --standalone \ --preferred-challenges http \ --server https://acme.corp.internal/acme/directory \ -d app.corp.internal ``` Certbot opens port 80, Hortval fetches `http://app.corp.internal/.well-known/acme-challenge/`, and on success submits the CSR to ADCS. The signed certificate is written to `/etc/letsencrypt/live/app.corp.internal/`. ## HTTP-01 (webroot) If a web server is already running on port 80, use `--webroot` instead of `--standalone`: ```bash export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt certbot certonly \ --webroot -w /var/www/html \ --preferred-challenges http \ --server https://acme.corp.internal/acme/directory \ -d app.corp.internal ``` Certbot writes the challenge file under `/var/www/html/.well-known/acme-challenge/`. Your web server must serve that path over HTTP. ## DNS-01 (for wildcards) HTTP-01 and TLS-ALPN-01 cannot validate wildcard names (`*.corp.internal`) — RFC 8555 §8.4 limits them to DNS-01. ```bash export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt certbot certonly \ --manual \ --preferred-challenges dns \ --server https://acme.corp.internal/acme/directory \ -d "*.corp.internal" ``` certbot prompts you interactively to add the `_acme-challenge.corp.internal` TXT record. For automation, install a DNS plugin matching your provider (`certbot-dns-route53`, `certbot-dns-rfc2136`, …) and use `--dns-` flags instead of `--manual`. ## Renewal Once the certificate is issued, certbot can renew it automatically via the `certbot.timer` systemd unit: ```bash # Test renewal export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt certbot renew --dry-run # Enable automatic renewal systemctl enable --now certbot.timer ``` Renewal config is stored under `/etc/letsencrypt/renewal/.conf` and inherits the flags used at the initial `certonly` call. ## Revocation ```bash certbot revoke --cert-name app.corp.internal \ --server https://acme.corp.internal/acme/directory ``` `--cert-name` is the directory name under `/etc/letsencrypt/live/`. Add `--no-delete-after-revoke` if you want to keep the files on disk after revocation. ## With Caddy (automatic, no certbot) If you run Caddy as your reverse proxy, it can handle ACME directly — no certbot involvement, no renewal scripts to write: ``` { acme_ca https://acme.corp.internal/acme/directory acme_ca_root /path/to/your/internal-ca.pem } app.corp.internal { reverse_proxy localhost:8080 } ``` Caddy uses HTTP-01 by default for non-wildcard names. For wildcards, configure a DNS module from [caddy-dns](https://github.com/caddy-dns). --- # clients/lego.md --- sidebar_position: 1 title: lego --- # lego [lego](https://go-acme.github.io/lego/) is a single static Go binary with no runtime dependency — ideal for container images, CI runners, and minimal Linux installs. This page covers the lego-specific bits; for the general onboarding flow and trust-store setup see [First Certificate](../getting-started/first-certificate.md). :::info Documented for lego **5.x** (tested with 5.0.2) lego 5.0 introduced a CLI breaking change: every flag (`--server`, `--email`, `--domains`, `--http`, `--dns`, etc.) is now a **subcommand flag**, not a global flag. The subcommand (`run`, `renew`, `revoke`) must come first. The legacy `renew` and top-level `revoke` are gone — use `run --renew-force` and `certificates revoke`. If you are still on lego 4.x, the global-flag-first syntax of the old documentation applies; consider upgrading. ::: ## What changes vs certbot - **Key type**: lego generates **ECDSA P-256** keys by default for both the ACME account and the certificate. This sidesteps the `signature.min-rsa-bits` policy entirely. To force RSA, pass `--key-type rsa3072` (or `rsa4096`). `rsa2048` will be refused under the default `min-rsa-bits: 3072` policy. See [FAQ → RSA-only templates](../reference/faq.md#rsa-only-templates). - **CSR EKU**: lego declares `serverAuth` only in its CSR — no `clientAuth` smuggling, no need to loosen `csr.allowed-extra-eku` on the policy. - **Trust store**: lego reads a single PEM file pointed at by the env var `LEGO_CA_CERTIFICATES`. Point it at your OS bundle to keep one source of truth: ```bash # Debian / Ubuntu export LEGO_CA_CERTIFICATES=/etc/ssl/certs/ca-certificates.crt # RHEL / CentOS / Rocky export LEGO_CA_CERTIFICATES=/etc/pki/tls/certs/ca-bundle.crt ``` - **No built-in scheduler**: unlike certbot's `certbot.timer` or acme.sh's `--install-cronjob`, lego expects you to wire renewal yourself via a systemd timer or cron job. :::note Command-line layout The `run`, `renew`, and `revoke` subcommands come **first**, then the flags that configure them (`--server`, `--email`, `--domains`, `--http`, `--dns`, ...). Only logging flags (`--log.level`, `--log.format`) and `--config` are global and may appear before the subcommand. ::: ## HTTP-01 (standalone) ```bash export LEGO_CA_CERTIFICATES=/etc/ssl/certs/ca-certificates.crt lego run \ --server https://acme.corp.internal/acme/directory \ --email ops@corp.internal \ --accept-tos \ --http \ --domains app.corp.internal \ --path /etc/lego ``` Cert + chain + key land under `/etc/lego/certificates/`: - `app.corp.internal.crt` — the full chain (leaf first, then any intermediates). - `app.corp.internal.key` — the private key. - `app.corp.internal.issuer.crt` — issuer cert only. `lego` opens port 80 inside the process to answer the HTTP-01 challenge, then exits. Port 80 must therefore be free at the moment of the call. On systems where another service holds port 80 permanently, see [HTTP-01 webroot](#http-01-webroot) below. ## HTTP-01 (webroot) When a web server is already serving on port 80, use the `webroot` solver instead. lego writes the challenge token to a directory of your choice; the web server only has to serve `/.well-known/acme-challenge/` from there. ```bash lego run \ --server https://acme.corp.internal/acme/directory \ --email ops@corp.internal \ --accept-tos \ --http.webroot /var/www/html \ --domains app.corp.internal \ --path /etc/lego ``` ## TLS-ALPN-01 ```bash lego run \ --server https://acme.corp.internal/acme/directory \ --email ops@corp.internal \ --accept-tos \ --tls \ --domains app.corp.internal \ --path /etc/lego ``` lego binds port 443 with the ACME-specific ALPN protocol; Hortval probes the IP at port 443 with ALPN `acme-tls/1` to verify ownership. Useful when port 80 is unavailable but port 443 is free. ## DNS-01 (for wildcards) lego ships built-in plugins for ~80 DNS providers — no extra package needed. Pick the one matching your DNS, configure it via env vars, and pass `--dns `. :::warning Point the propagation check at your internal resolver By default, lego waits until the just-installed `_acme-challenge.` TXT record is visible through public resolvers (Cloudflare `1.1.1.1`, Google `8.8.8.8`) before notifying Hortval. On an internal-only deployment that check **will never succeed** — the names do not exist publicly — and worse, the internal domain name is leaked in cleartext to those resolvers. Use `--dns.resolvers=` to point the propagation probe at your authoritative internal resolver instead. This keeps the sanity check (detects a misconfigured plugin) without leaking anything outside: ```bash lego run \ --dns rfc2136 \ --dns.resolvers '10.0.0.53:53' \ ... ``` If the propagation check is not desired at all (synchronous plugin, very short TTLs, etc.) pass `--dns.propagation.disable-ans` (authoritative nameservers) and/or `--dns.propagation.disable-rns` (recursive resolvers) to skip it entirely. `--dns.propagation.wait ` replaces the check with a fixed sleep when neither flag fits. ::: ```bash export LEGO_CA_CERTIFICATES=/etc/ssl/certs/ca-certificates.crt # Example: RFC 2136 / nsupdate against an internal Bind server export RFC2136_NAMESERVER='10.0.0.53' export RFC2136_TSIG_KEY='acme.' export RFC2136_TSIG_SECRET='' export RFC2136_TSIG_ALGORITHM='hmac-sha256.' lego run \ --server https://acme.corp.internal/acme/directory \ --email ops@corp.internal \ --accept-tos \ --dns rfc2136 \ --dns.resolvers '10.0.0.53:53' \ --domains '*.corp.internal' \ --path /etc/lego ``` For internal infrastructures without a public DNS provider, `rfc2136` against your own Bind/PowerDNS is usually the simplest path. The full list of providers is in lego's [DNS providers documentation](https://go-acme.github.io/lego/dns/). ## Renewal lego has no built-in scheduler. `lego run` itself issues or renews depending on whether the cert is already on disk — call it on a schedule (systemd timer, cron) and pass `--renew-days N` to actually do the renew only when the cert is within N days of expiration. Add `--renew-force` if you want to force a renew regardless: ```bash lego run \ --server https://acme.corp.internal/acme/directory \ --email ops@corp.internal \ --accept-tos \ --http \ --domains app.corp.internal \ --path /etc/lego \ --renew-days 30 ``` Minimal systemd setup (`/etc/systemd/system/lego-renew.service` + `lego-renew.timer`): ```ini # lego-renew.service [Unit] Description=Renew certificates via lego [Service] Type=oneshot Environment=LEGO_CA_CERTIFICATES=/etc/ssl/certs/ca-certificates.crt ExecStart=/usr/local/bin/lego run \ --server https://acme.corp.internal/acme/directory \ --email ops@corp.internal \ --accept-tos \ --http \ --domains app.corp.internal \ --path /etc/lego \ --renew-days 30 # lego-renew.timer [Unit] Description=Daily lego renewal check [Timer] OnCalendar=daily Persistent=true RandomizedDelaySec=4h [Install] WantedBy=timers.target ``` Enable with `systemctl enable --now lego-renew.timer`. ## Revocation ```bash LEGO_PATH=/etc/lego lego certificates revoke \ --server https://acme.corp.internal/acme/directory \ --email ops@corp.internal \ --cert.name app.corp.internal ``` `--cert.name` is the certificate's ID/name on disk — by default this is the first domain you passed at issuance. `certificates revoke` does not accept `--path` or `--domains`; pass the storage location via the `LEGO_PATH` environment variable instead. Revoke uses the account key — no need to present the certificate key separately. --- # configuration/adcs.md --- sidebar_position: 2 title: ADCS Configuration --- # ADCS Configuration :::caution Work in progress This page is not yet complete. Content and best practices will be added shortly. ::: :::tip Connector choice Hortval reaches ADCS through a **native in-process connector by default** (`type: adcs`), with a `certreq.exe` fallback (`type: adcs-cli`). See [Authorities → Connector](./authorities.md#connector-native-default-or-certreqexe). ::: This page will cover: - Prerequisites on the ADCS host - Creating a certificate template for ACME enrollment - Setting the correct permissions (enroll rights for the Hortval service account) - Finding the correct `ca-name` value (`certutil -CA`) - Recommended template settings (key usage, validity, issuance requirements) - Security best practices (least-privilege service account, auditing, etc.) ## Permissions: enrollment vs revocation Hortval needs **different CA privileges depending on what it does**: | Operation | Required ADCS rights | |---|---| | **Enrollment** (issuing certificates) | *Read* + *Enroll* on the template, and *Request Certificates* on the CA. | | **Revocation** (propagating revocations to the CA) | The **Certificate Manager** role — the *Issue and Manage Certificates* permission on the CA. This is a CA-administration privilege, higher than enrollment. | If the Hortval service account has enrollment rights but **not** the Certificate Manager role, issuance works while revocation fails with *Access Denied*. If you cannot (or do not want to) grant that role, disable CA propagation per authority with `disable-ca-revocation: true` — see [Authorities → Revocation](./authorities.md#revocation) for the full revocation behavior (asynchronous publication, authorization modes, accepted reason codes). :::tip Use a dedicated template We recommend a certificate template dedicated to Hortval. It lets the key requirement, the SAN policy and the Certificate Manager permission all be scoped to Hortval, with no side effects on your other templates. ::: :::note RSA-only templates If your template mandates RSA (e.g. a minimum key size of 4096), both Hortval's own certificate and the ACME clients' certificates must use RSA. See the FAQ: [My PKI / ADCS template only allows RSA](../reference/faq.md#rsa-only-templates). ::: ## Serving both EC and RSA clients An ADCS template can pin the **key algorithm**, not only the key size — the CNG settings a template carries (`msPKI-RA-Application-Policies`) can require, say, `ECDH_P256`. Such a template refuses an RSA request, and vice versa. Meanwhile ACME clients disagree on their default: lego and certbot generate ECDSA, acme.sh generates RSA. One pinned template therefore splits your client population in two. **The refusal is hard to read**, which is the reason to know about it in advance. The CA accepts the request, applies the template, builds the certificate content — and *then* denies it: ``` ADCS denied the request (CR_DISP_DENIED): 0x80094003 CA message: Denied by Policy Module ``` Nothing there names the key. In particular it is **not** `CERTSRV_E_KEY_LENGTH`: an RSA 3072 key satisfies a template whose *minimum key size* is 256, so the size check passes and only the algorithm mismatches. Enrollment rights are not involved either — other clients keep succeeding against the same template with the same account. You have two options. ### Option 1 — one template that accepts both Leave the template without a CNG algorithm requirement and it signs whatever the client brings. Simplest, and it is what a `WebServer` clone does by default. The cost: a single *minimum key size* now has to cover both families, so a value low enough for P-256 also accepts a weak RSA key. ### Option 2 — one authority per template Keep the pinned templates and declare **one authority per template**. The routing then happens at the authority, not at the policy the client sees: ```yaml authorities: - name: ca-ec type: adcs policies: - ec-only # this authority's own policy configuration: ca-name: "PKI\\CORP-CA" certificate-template: "ACME-Server-EC" - name: ca-rsa type: adcs policies: - rsa-only configuration: ca-name: "PKI\\CORP-CA" certificate-template: "ACME-Server-RSA" issuance-policies: # The policy ACME orders are bound to. It must accept BOTH key types — # see the caution below. - name: corp-server dns: allow: - ".corp.example/3" - name: ec-only dns: allow: - ".corp.example/3" signature: allowed-algorithms: ["ECDSA-SHA256"] allowed-ec-curves: ["P-256"] - name: rsa-only dns: allow: - ".corp.example/3" signature: allowed-algorithms: ["RSA-SHA256"] min-rsa-bits: 3072 policy-bindings: - policy: corp-server authorities: [ca-ec, ca-rsa] - policy: ec-only authorities: [ca-ec] - policy: rsa-only authorities: [ca-rsa] ``` At finalize, Hortval validates the CSR against **each candidate authority's own policies** and keeps the authorities that accept it. An ECDSA CSR satisfies `ec-only` and fails `rsa-only`, so it is issued from `ACME-Server-EC`; an RSA CSR does the opposite. Each family also gets its own floor — P-256 on one side, RSA 3072 on the other — which a single template cannot express. :::caution Do not constrain the key on the order's policy `corp-server` deliberately carries **no `signature:` block**. The policy an ACME order is bound to is chosen when the order is created — *before the client has sent its CSR* — from the requested DNS names alone, and it is pinned there so that a client cannot steer which CA template signs its request. Adding a key constraint to it would therefore **reject** one family of clients outright instead of routing it: the order is already pinned by the time the CSR arrives, and the other policy is never consulted. Two consequences: - **Declaration order matters.** Several policies covering the same names are legitimate; the first one whose names validate wins the order. Keep the order-facing policy first. - **The template becomes the floor for what the order policy no longer states.** A CSR the order policy accepts can still be refused later, either by an authority policy or by the CA itself. ::: :::caution An issuance policy is not a proxy for the template The policy says what ACME clients are **allowed to ask for**; the template says what the CA will **sign**. Hortval does not read the template, so nothing keeps the two aligned — you align them by hand. A policy looser than the template turns a clean refusal by Hortval into a CA denial mid-issuance; a policy stricter than the template refuses requests the CA would have signed. ::: With a single template and a single authority, none of this applies: keep one policy and omit `policy-bindings` entirely. ## Preflight your setup: `hortval adcs check` Before starting the server, verify the ADCS setup against the live CA: ```bash hortval adcs check -f config.yml ``` It is **read-only** — no certificate is requested — and reports, for each ADCS authority: - whether the CA is reachable, - whether the certificate template is **published**, - the template's **key requirement** (so you can set the matching `key:` and issuance policy). Run it on the Hortval host (it uses `certutil`, so it is Windows-only; on other platforms it reports "skipped"). Exit code `0` means every check passed, `1` means at least one failed. The interactive [`hortval init`](../getting-started/wizard.md) wizard runs the same checks: it lists the CA's published templates so you pick the exact one, and reads the template's key requirement to set the server certificate key for you. --- # configuration/authorities.md --- sidebar_position: 6 title: Authorities --- # Authorities Authorities are the PKI backends that Hortval submits certificate requests to. Each authority represents one ADCS instance (or a fake PKI for testing). ## Configuration ```yaml authorities: - name: ca1 type: adcs # native connector (default) — see "Connector" below configuration: ca-name: "PKI\\LAB-RootCA" certificate-template: "ACME-Template-Server" default-timeout: 4m ``` ## Fields | Field | Required | Description | |---|---|---| | `name` | Yes | Unique authority name. Referenced in policy bindings. | | `type` | Yes | Authority type: `adcs` / `adcs-native` (native connector, default) · `adcs-cli` (certreq.exe connector) · `fake` (testing) | | `policies` | No | Remote authority policy constraints (advanced). If omitted, all local policies are candidates. | | `disable-ca-revocation` | No (default `false`) | When `true`, keep ACME revocations local to Hortval instead of propagating them to the backing CA (CRL/OCSP). Leave unset (or `false`) to propagate. See [Revocation](#revocation). | | `configuration` | Yes | Type-specific configuration block (see below) | ## ADCS Authority ### Connector: native (default) or certreq.exe Hortval talks to ADCS through one of two interchangeable connectors. Both issue the same certificates from the same `ca-name` and `certificate-template` — only the integration method differs. | `type` | Connector | Notes | |---|---|---| | `adcs` (default), `adcs-native` | **Native** — Hortval enrolls in-process through the built-in Windows certificate API. No external program is launched. | Recommended. Nothing extra to install, and the cleanest fit for hardened, EDR-monitored hosts (see [Antivirus & EDR](../administration/antivirus-edr.md)). | | `adcs-cli` | **certreq.exe** — Hortval drives the standard Windows `certreq.exe` tool. | Choose this if you prefer the classic `certreq.exe` integration, or want it as a fallback. | `type: adcs` resolves to the native connector, so an existing configuration moves to it automatically on upgrade — no change required. Both connectors are Windows-only: ADCS enrollment runs on a Windows host joined to, or able to reach, the CA. :::tip Prefer the native connector at scale On large or high-throughput deployments, **use the native connector** (`adcs` / `adcs-native`). It runs in-process and bounds concurrency to the worker pool, whereas `adcs-cli` **spawns a child process per operation** (`certreq.exe` to issue, `certutil.exe` to revoke) — process-creation overhead, temp-file churn and OS process limits add up under load. Keep `adcs-cli` for compatibility or as a fallback, not for heavy issuance/revocation volume. ::: ### Configuration Fields | Field | Default | Applies to | Description | |---|---|---|---| | `ca-name` | — | both | Full CA name as shown by `certutil -CA` (e.g. `PKI\LAB-RootCA`) | | `certificate-template` | — | both | ADCS certificate template name for ACME issuance | | `default-timeout` | `4m` | both | Maximum wait time for a single ADCS request. Keep it **below** `workers.max-job-duration` (default `5m`) so the ADCS timeout — not the surrounding job deadline — bounds the call; Hortval warns at startup if it is greater than or equal to `max-job-duration`. | | `certreq-path` | system directory | `adcs-cli` only | Path to `certreq.exe` (enrollment). Ignored by the native connector. | | `certutil-path` | system directory | `adcs-cli` only | Path to `certutil.exe`, used for **revocation** (`certutil -revoke`). `certutil` is a different binary from `certreq`. Ignored by the native connector. Only relevant when revocation propagation is enabled (i.e. `disable-ca-revocation` is not set). | **Since 0.9.4**, both binaries are taken from the Windows system directory (typically `C:\Windows\System32`) instead of being looked up through `%PATH%`. Leaving the key unset — or giving just a file name, with or without the `.exe` extension — resolves there. Set a full path only to run a copy kept elsewhere, for instance on a dedicated path carved out of an Endpoint Detection and Response (EDR) policy: ```yaml certutil-path: "C:\\Tools\\certutil.exe" ``` A path relative to the working directory (`tools\certutil.exe`) is refused at startup and by `hortval validate`. ### Finding your CA Name ```powershell certutil -CA ``` The output shows the CA name in the format `Machine\CAName`. Use this exact string in `ca-name`. ### Certificate Template Requirements The ADCS template must: - Allow enrollment by the Hortval service account - Be configured for **Web Server** or equivalent (Server Authentication EKU) - Not have conflicting subject policies that would override the CSR :::tip Create a dedicated template for Hortval (e.g. `ACME-Template-Server`) rather than reusing an existing one. This isolates the configuration and simplifies auditing. ::: ### Revocation When an ACME client revokes a certificate (RFC 8555 §7.6), Hortval marks it revoked in its own database and, by default, **propagates the revocation to the backing CA** so the certificate also appears revoked in the CA's CRL/OCSP. ```yaml authorities: - name: ca1 type: adcs disable-ca-revocation: false # default — propagate to the CA's CRL/OCSP configuration: ca-name: "PKI\\LAB-RootCA" certificate-template: "ACME-Template-Server" ``` **Revocation requires higher privileges than enrollment.** Issuing certificates needs only *Read* + *Enroll* on the template and *Request Certificates* on the CA. Revoking requires the **Certificate Manager** role — the *Issue and Manage Certificates* permission on the CA. If the Hortval service account lacks it, enrollment keeps working but revocation fails with an *Access Denied* error (surfaced in the audit log as `certificate.revoke.publish_failed`). Revocation is **asynchronous**: the ACME client receives its `200` immediately, and Hortval publishes to the CA in the background, retrying with an escalating backoff (minutes to hours) if the CA is temporarily unreachable. RFC 8555 §7.6 does not require publication to be confirmed before responding. Set `disable-ca-revocation: true` to keep revocation **local to Hortval** (no CA propagation). This is appropriate when: - the service account cannot be granted the Certificate Manager role, or - you intentionally rely on short-lived certificates and local revocation only. Note that this is a **privilege** decision, not a connectivity one: revocation targets the same CA host and channel as enrollment, so a CA reachable enough to issue is reachable enough to revoke. With propagation disabled, the audit log records `certificate.revoke.skipped` instead of `certificate.revoke.published`. Two authorization modes are accepted, per RFC 8555 §7.6: - **Account key** — the account that owns the certificate signs the request. - **Certificate key** — the request is signed with the certificate's own private key (the canonical "the key has leaked, revoke it" path). This works even without the issuing account, since possession of the private key is the proof. The following CRL reason codes are accepted: `0` unspecified, `1` keyCompromise, `2` cACompromise, `3` affiliationChanged, `4` superseded, `5` cessationOfOperation, `9` privilegeWithdrawn, `10` aACompromise. The stateful codes `6` (certificateHold) and `8` (removeFromCRL) are rejected, because Hortval revocation is terminal (no hold/un-revoke lifecycle). ## Fake PKI Authority (Testing) The `fakepki` authority type is a built-in self-signed CA for local testing. It does not connect to any external system. ```yaml authorities: - name: test-ca type: fake configuration: common-name: "Hortval Test CA" password: "testpassword" key-size: 4096 validity: 3650 # CA certificate lifetime, in days certificate-validity: 2160h # issued-certificate lifetime (default 90 days) ``` ### Fake PKI Configuration Fields | Field | Description | |---|---| | `common-name` | CN of the fake CA certificate. **Required.** | | `password` | Password encrypting the CA key on disk. **Required** — whoever reads that file otherwise becomes the authority. | | `key-size` | RSA key size for the CA, **applied only when the CA is generated**. Defaults to `4096` when omitted. | | `validity` | Lifetime of the **CA** certificate, in days. Defaults to `3650` when omitted. | | `certificate-validity` | Lifetime of **issued** certificates (Go duration, e.g. `2160h`). Also bounds the CRL: a revoked serial is purged at `RevocationTime + certificate-validity` (it would be expired anyway), so the CRL cannot grow without bound. Default 90 days. | :::info The CA key cannot be weaker than what it signs `key-size` must be at least the highest `min-rsa-bits` of the issuance policies bound to this authority — `3072` by default — with a hard floor of `2048`. A CA signing 3072-bit certificates with a 2048-bit key is refused at startup, and by `hortval validate`. ::: :::caution `key-size` only applies when the CA is generated An existing `ca.key` is loaded as it stands and is **never** regenerated — replacing it would invalidate every certificate the authority has issued. So on an authority that already has a CA, changing `key-size` changes nothing: the key keeps the size it was created with, and the value in the configuration describes an intention rather than what runs. Hortval logs a warning at startup when the two disagree, giving the size in use and the configured one. Moving to a stronger CA is a deliberate rollover: create a new authority and re-issue, rather than editing this field. Note the two checks fire at different moments. `key-size` against the policy floor is a **static** check, so `hortval validate` and the startup gate both refuse it before anything else happens. The `common-name` check compares the configuration with the certificate on disk, so it can only run once the authority is opened, later in startup. ::: :::warning The `fake` authority is for development and testing only. Do not use it in production. ::: ## Multiple Authorities You can define multiple ADCS authorities for redundancy or to serve different policies: ```yaml authorities: - name: adcs-primary type: adcs configuration: ca-name: "PKI\\Primary-CA" certificate-template: "ACME-Server" - name: adcs-backup type: adcs configuration: ca-name: "PKI\\Backup-CA" certificate-template: "ACME-Server" ``` Then reference both in a [policy binding](./policy-bindings.md) with `strategy: first_available`. --- # configuration/database.md --- sidebar_position: 2 title: Database --- # Database Hortval stores all ACME state (accounts, orders, challenges, certificates, audit logs) in a relational database. ## Supported Drivers | Driver | Key | Notes | |---|---|-----------------------------------------------------------------------------------------------------| | SQLite | `sqlite` | Default. No setup required. Recommended for single-node deployments. Do not supports multiple nodes | | PostgreSQL | `postgres` | Recommended for multi node deploymnent. | | SQL Server | `sqlserver` | For environments standardized on Microsoft SQL Server. | ## Configuration ```yaml database: driver: postgres dsn: "postgres://hortval:secret@db01:5432/hortval?sslmode=require" ping-timeout-sec: 5 max-idle-conn: 5 max-conn: 10 conn-max-lifetime: 2m conn-max-idle-time: 1m ``` ### SQLite (default) If `database` is omitted entirely, Hortval uses SQLite at `%WORKDIR%/db.sqlite`. ```yaml # Explicit SQLite config database: driver: sqlite path: "C:\\ProgramData\\hortval\\db.sqlite" ``` ### PostgreSQL ```yaml database: driver: postgres dsn: "postgres://hortval:secret@db01:5432/hortval?sslmode=require" ``` ### SQL Server ```yaml database: driver: sqlserver dsn: "sqlserver://hortval:secret@sqlserver01:1433?database=hortval" ``` #### Windows integrated authentication On Windows, Hortval can connect as the account it runs under, so no SQL password appears in the configuration file. Add `authenticator=winsspi` and drop the credentials: ```yaml database: driver: sqlserver dsn: "sqlserver://sqlserver01:1433?database=hortval&authenticator=winsspi" ``` The identity used is the **account of the Hortval process**, so it is the one that needs a SQL Server login and permissions on the database. A domain account or a group Managed Service Account (gMSA) — see [Installation](../getting-started/installation.md#windows-service) for the account model. :::caution `LocalSystem` presents itself as the machine account A service created with `sc.exe` and no `obj=` runs as `LocalSystem`, which authenticates to SQL Server as `DOMAIN\MACHINE$`. Grant that, and every service on the host inherits database access. Give Hortval its own account. ::: Windows only — the provider is compiled into the Windows binary alone. Linux and macOS builds use username and password. #### Check that it is really Kerberos An integrated connection can fall back to **NTLM with no error at all**: it connects, and everything appears to work. Hortval therefore reports the method it obtained, once, at startup: ``` INFO SQL Server: integrated authentication account=DOMAIN\svc-hortval auth_scheme=KERBEROS WARN SQL Server: the connection fell back to NTLM because no Service Principal Name … ``` If the account cannot read `sys.dm_exec_connections`, Hortval says that too rather than staying silent — an absent line would be ambiguous. To check independently, or from a DBA session, run this against the instance as the account Hortval runs under: ```sql SELECT SUSER_SNAME(), auth_scheme FROM sys.dm_exec_connections WHERE session_id = @@SPID; ``` `KERBEROS`, together with the expected account name, is the answer you want. `NTLM` means the connection is integrated but no **Service Principal Name** is registered. It works — and it will keep working, until the day your domain restricts NTLM. Hortval then stops connecting, and the outage looks like a Hortval problem rather than what it is: a dependency that was there from the first day and that nothing ever reported. NTLM is also relayable where Kerberos is not, and the account Hortval uses holds schema and write rights on your certificate store. Either way the SQL password is out of `config.yml` — that part of the benefit does not depend on which one you got. The SPN belongs to the account the **SQL Server service** runs under, which is the machine account when that service runs as `LocalSystem` or `NetworkService`: ```powershell setspn -S MSSQLSvc/sqlserver01.example.com:1433 EXAMPLE\SQLSERVER01$ setspn -S MSSQLSvc/sqlserver01:1433 EXAMPLE\SQLSERVER01$ ``` If `setspn` cannot bind to a domain controller — some hardened domains reject its LDAP bind — the ActiveDirectory module writes the same attribute over ADWS: ```powershell Set-ADComputer -Identity SQLSERVER01 -ServicePrincipalNames @{ Add = 'MSSQLSvc/sqlserver01.example.com:1433', 'MSSQLSvc/sqlserver01:1433' } ``` An SPN carries a **port**. One registered for a port the instance does not listen on matches nothing, and the connection quietly falls back to NTLM — so fix the port the instance listens on first, register the SPN second. :::note A local connection never proves anything Connecting from the machine that hosts SQL Server goes over shared memory or named pipes, and reports `NTLM` even when everything is configured correctly. Run the check from another host — which is also how Hortval will connect. ::: ## Fields | Field | Default | Description | |---|---|---| | `driver` | `sqlite` | Database driver: `sqlite`, `postgres`, `sqlserver` | | `dsn` | — | Connection string (PostgreSQL and SQL Server) | | `path` | `%WORKDIR%/db.sqlite` | File path (SQLite only) | | `noddl` | `false` | The application account holds no schema rights. Hortval never issues DDL: it checks the schema at startup, refuses to run if anything is missing, and `hortval migrate` writes the SQL for your DBA instead of applying it. See [Migrations](../administration/migrations.md). | | `ping-timeout-sec` | `10` | Timeout for the startup connectivity check | | `max-idle-conn` | `2` (SQLite), `5` (others) | Maximum idle connections | | `max-conn` | `10` | Maximum open connections | | `conn-max-lifetime` | `2m` (PostgreSQL / SQL Server), unset for SQLite | Hard cap on a pooled connection's total lifetime. The default sits **strictly below** typical firewall / NAT idle timeouts (5–15 min) so the pool recycles **before** the network drops a stale connection — otherwise the next use surfaces as `Read: EOF`. `0` disables the cap. | | `conn-max-idle-time` | `1m` (PostgreSQL / SQL Server), unset for SQLite | A connection idle in the pool longer than this is closed. Complements `conn-max-lifetime` for hosts that drop idle sockets aggressively. `0` disables. | ## Migrations The schema travels inside the binary — no external SQL files. A restart applies **additive** migrations on its own; anything that cannot be undone by doing nothing waits for an explicit `hortval migrate`. See [Migrations](../administration/migrations.md) for the full contract, the `--sql` output, and the `noddl` mode. ## Schema Hortval writes to the schema its database account resolves to, and says which one at every start: ``` Database schema in use schema=public ``` Two instances sharing a database **and a schema** share their data. That is a valid multi-node deployment — and an accident that looks identical from the database's side. For two separate installations, give each one a schema: ```yaml # PostgreSQL: the default search path sends everyone to `public` database: driver: postgres dsn: "postgres://hortval:secret@db01:5432/shared?options=-csearch_path%3Dhortval" ``` On SQL Server the schema comes from the database user, not the connection string — use one user per installation, each with its own `DEFAULT_SCHEMA`. ## Schema Reference See [Schema Reference](../administration/schema.md) for the full list of tables and their lifecycle. --- # configuration/dns-profiles.md --- sidebar_position: 3 title: DNS Validation Profiles --- # DNS Validation Profiles DNS validation profiles define **how Hortval resolves and validates DNS challenges**. Each profile controls which DNS zones are in scope, which resolver to use, and which resolved IP addresses are acceptable. ## Configuration ```yaml dns-validation-profiles: - name: internal-default mode: local zones: - suffixes: - "corp.internal" system: true authoritative: false dnssec: false protocol: udp resolved-ip-policy: allow-cidrs: - "10.0.0.0/8" deny-cidrs: - "127.0.0.0/8" - "169.254.0.0/16" - "::1/128" - "fe80::/10" ``` ## Fields ### Profile | Field | Default | Type | Description | |---|---|----------|------|---------------------------------------------| | `name` | — | string | Unique profile name. Referenced by issuance policies. | | `mode` | `local` | string | Validation mode. Only `local` is currently available. | | `timeout` | — | duration | Overall validation timeout. | ### Zones Each zone entry defines which DNS zones this profile handles and how to resolve them. | Field | Default | Type | Description | |---|---|-------------------------------------|--------------------------------------------------| | `suffixes` | — | List string | List of DNS zone suffixes (e.g. `corp.internal`) | | `system` | — | boolean | Use the system resolver for this zone | | `dns-server` | — | List string | Explicit DNS server address (overrides system) | | `authoritative` | — | boolean | Require authoritative responses | | `dnssec` | — | boolean | Require DNSSEC validation | | `protocol` | `udp` | string | DNS transport. `tcp` queries TCP directly (no UDP attempt, no fallback) — use it on networks where UDP/53 is blocked. `udp` (default; empty means `udp`) queries UDP first and falls back to TCP only if the reply is truncated. | ### Resolved IP Policy After a challenge DNS name resolves, Hortval checks the resulting IP against these rules. | Field | Type | Description | |---|---------------------------------------------------------------------|-------------------------------| | `allow-cidrs` | List string | IP ranges that are acceptable | | `deny-cidrs` | List string | IP ranges that are explicitly rejected (loopback, link-local, etc.) | Deny rules are evaluated first. If an IP matches a deny CIDR, the challenge fails regardless of allow rules. :::warning At least one of the two is required A profile with neither `allow-cidrs` nor `deny-cidrs` is refused at startup, and by `hortval validate`. An empty policy would accept every address DNS returns — link-local and cloud metadata (`169.254.169.254`) included — and Hortval will not treat "unset" as "open". If you genuinely accept any address, say so: ```yaml resolved-ip-policy: allow-cidrs: - "0.0.0.0/0" - "::/0" ``` This applies to every profile, whichever challenge you use. Challenge types cannot be restricted per profile: every non-wildcard authorization offers `dns-01`, `http-01` and `tls-alpn-01`, and the client picks — so a profile that has only ever seen `dns-01` still offers the paths this policy guards. ::: ## Multiple Profiles You can define multiple profiles for different DNS zones or resolution strategies: ```yaml dns-validation-profiles: - name: corp-internal mode: local zones: - suffixes: - "corp.internal" system: true resolved-ip-policy: allow-cidrs: - "10.0.0.0/8" - name: dmz mode: local zones: - suffixes: - "dmz.example.com" dns-server: - "192.168.100.1" resolved-ip-policy: allow-cidrs: - "172.16.0.0/12" ``` When multiple profiles exist, each [issuance policy](./issuance-policies.md) must explicitly reference the profile to use. --- # configuration/issuance-policies.md --- sidebar_position: 4 title: Issuance Policies --- # Issuance Policies Issuance policies define **which certificate requests Hortval will accept** and what constraints apply. Every order is evaluated against an issuance policy before any certificate is issued. ## Configuration ```yaml issuance-policies: - name: corp-server dns-validation-profile: internal-default dns: allow: - ".corp.internal/3" - "*.corp.internal" deny: - "=forbidden.corp.internal" signature: allowed-algorithms: - "RSA-SHA256" - "RSA-SHA384" - "RSA-SHA512" - "ECDSA-SHA256" - "ECDSA-SHA384" - "ECDSA-SHA512" - "ED25519" min-rsa-bits: 3072 allowed-ec-curves: - "P-256" - "P-384" ``` ## Fields | Field | Required | Description | |---|---|---| | `name` | Yes | Unique policy name | | `dns-validation-profile` | Conditional | Profile to use for challenge validation. Required if more than one profile exists. | | `dns.allow` | Yes | DNS scope rules (see below). Must not be empty. | | `dns.deny` | No | DNS names to explicitly reject | | `signature.allowed-algorithms` | No | Allowed signing algorithms. Empty = secure defaults. **A non-empty list replaces the defaults — it does not add to them.** | | `signature.min-rsa-bits` | No | Minimum RSA key size. Default: `3072`. Floor: `2048`. | | `signature.allowed-ec-curves` | No | Allowed EC curves. Empty = secure defaults. **A non-empty list replaces the defaults — it does not add to them.** | ## DNS Scope Rules The `dns.allow` list controls which DNS names Hortval will accept in a CSR. Each rule uses a compact grammar. ### Rule: Non-wildcard zone with depth limit **Syntax:** `.zone/N` Allows non-wildcard names under `zone` with at most `N` labels before the zone. ``` .corp.internal/2 ``` Allowed: `app.corp.internal`, `api.app.corp.internal` Rejected: `a.b.c.corp.internal` (3 labels), `*.corp.internal` (wildcard) ### Rule: Wildcard only at zone **Syntax:** `*.zone` Allows only the exact wildcard `*.zone`. Does not allow non-wildcard names. ``` *.corp.internal ``` Allowed: `*.corp.internal` Rejected: `app.corp.internal`, `*.sub.corp.internal` To allow both, combine two rules: ```yaml allow: - ".corp.internal/2" - "*.corp.internal" ``` ### Rule: Wildcard in subzones only **Syntax:** `*..zone/N` Allows wildcards inside subzones of `zone`, but not directly under `zone`. ``` *..corp.internal/2 ``` Allowed: `*.app.corp.internal` Rejected: `*.corp.internal` (directly under zone), `*.a.b.corp.internal` (too deep for `/2`) ### Rule: Exact match **Syntax:** `=name` Allows or denies an exact DNS name. ```yaml deny: - "=legacy.corp.internal" ``` ## DNS Name Normalization Before matching, all DNS names are: - Lowercased - Trailing dot removed - Rejected if they contain empty labels (`..`) or whitespace ## CSR Extension Whitelist (Extended Key Usage) Hortval validates the contents of the CSR's `extensionRequest` strictly: only DNS-typed SANs and the Extended Key Usage extension (EKU, OID `2.5.29.37`) are accepted. By default the **only EKU value tolerated is `serverAuth`** (OID `1.3.6.1.5.5.7.3.1`) — the appropriate purpose for a public-server TLS certificate. To accept additional EKU values, opt in per policy: ```yaml issuance-policies: - name: lab-server csr: allowed-extra-eku: - clientAuth # - codeSigning # - emailProtection # - timeStamping # - ocspSigning # - anyPurpose # - "1.3.6.1.4.1.311.10.3.4" # raw OID also accepted ``` `serverAuth` is implicit and does not need to be listed. :::warning Security Adding entries to `allowed-extra-eku` lets ACME clients request certificates with non-server-TLS purposes through that policy. Whether the issued certificate actually carries those EKUs depends on the back-end CA template: - **ADCS templates configured as "Build from this Active Directory information"** ignore the CSR's EKU and apply the template's own. Adding entries here has no effect on the issued cert. - **ADCS templates configured as "Supply in the request"** honor the CSR's EKU. The issued cert will carry whatever the CSR asked for, as long as the template permits it. Only loosen this for policies whose authority you trust to enforce purpose constraints — e.g. a dedicated code-signing authority + template + audit trail. For the typical "Web Server" use case, leave it empty. ::: ### Note on `clientAuth` and the CA/B Forum baseline For most of TLS history, server certificates routinely declared both `serverAuth` and `clientAuth` in their Extended Key Usage. Some popular ACME clients still do this by default — notably **acme.sh**, whose built-in CSR template emits `extendedKeyUsage = serverAuth, clientAuth`. Without `clientAuth` in `allowed-extra-eku`, those CSRs are refused. The CA/B Forum's TLS Baseline Requirements **forbid this combination from June 2026 onwards**: a publicly-trusted server certificate must declare `serverAuth` only. Hortval is most often deployed against an internal ADCS — outside the public WebPKI — so the rule is advisory rather than binding for your deployment, but mirroring the public-trust posture is good hygiene. Two practical positions: 1. **Strict (recommended for new deployments)**: leave `allowed-extra-eku` empty. Use lego or certbot, which emit `serverAuth` only by default. acme.sh works after a one-line override of its OpenSSL template. 2. **Pragmatic (existing acme.sh fleet)**: add `clientAuth` to `allowed-extra-eku` so existing scripts keep working, and plan a migration once the fleet has moved off acme.sh's default template. :::tip On a "build from AD information" template, the pragmatic path costs nothing The two positions above are usually presented as a trade-off, and on ADCS with a template that builds the EKU itself, it is not one: acme.sh **asks** for `clientAuth`, the CA **does not grant it**, and acme.sh **does not care**. Verified 2026-08-21 against a lab ADCS. The CSR carried `serverAuth + clientAuth`; the issued certificate came back with: ``` Application Policies [1] Policy Identifier = Server Authentication ← Origin=Policy ``` `Origin=Policy` means the value came from the template, not from the request. acme.sh accepted that certificate and completed normally. So `allowed-extra-eku: [clientAuth]` decides only whether **Hortval** refuses the request; it does not decide what the certificate contains. With such a template you satisfy the CA/B Forum posture — no `clientAuth` is ever issued — while existing acme.sh clients keep working unchanged. This does **not** hold on a "supply in the request" template, where the CSR's EKU is honored. There, the two positions are a real trade-off again. ::: ## Signature Defaults If `signature` is omitted: - `min-rsa-bits`: `3072` - `allowed-algorithms`: when empty, a secure default set applies — `RSA-SHA256`, `RSA-SHA384`, `RSA-SHA512`, `ECDSA-SHA256`, `ECDSA-SHA384`, `ED25519` - `allowed-ec-curves`: internal secure defaults (P-256, P-384) The default set is deliberately **narrower** than what you may configure. The full configurable set adds `ECDSA-SHA512`; anything outside it is rejected when the configuration is loaded, not at issuance time. ## Two rules that decide what a policy really accepts Most surprises with `signature` come from these two, and they compound. ### 1. The lists replace the defaults, they do not extend them Writing one value does not add it to the defaults — it **discards** the rest. `allowed-ec-curves: ["P-521"]` does not mean "P-256, P-384 and also P-521"; it means "P-521 only". This is deliberate: it is what lets you constrain a policy to match a back-end CA template (see [FAQ — reject a wrong key early](../reference/faq.md)). But it means you must list **every** value you want. ### 2. Each EC curve is pinned to one algorithm | Curve | Requires | |---|---| | `P-256` | `ECDSA-SHA256` | | `P-384` | `ECDSA-SHA384` | | `P-521` | `ECDSA-SHA512` | Allowing a curve without its algorithm — or an algorithm without its curve — makes that entry unusable. ### What each configuration actually accepts | Configuration | RSA | P-256 | P-384 | P-521 | Ed25519 | |---|:--:|:--:|:--:|:--:|:--:| | no `signature` block | ✅ | ✅ | ✅ | ❌ | ✅ | | `allowed-ec-curves: ["P-521"]` only | ✅ | ❌ | ❌ | ❌ | ✅ | | `allowed-algorithms: ["ECDSA-SHA512"]` only | ❌ | ❌ | ❌ | ❌ | ❌ | | both, `P-521` / `ECDSA-SHA512` only | ❌ | ❌ | ❌ | ✅ | ❌ | | every algorithm **and** every curve listed | ✅ | ✅ | ✅ | ✅ | ✅ | Row 2 is the common trap: adding `P-521` on its own loses P-256 and P-384 (rule 1) **and** does not enable P-521 (rule 2). To add a curve, write both lists in full: ```yaml signature: allowed-ec-curves: - "P-256" - "P-384" - "P-521" # added allowed-algorithms: - "RSA-SHA256" - "RSA-SHA384" - "RSA-SHA512" - "ECDSA-SHA256" - "ECDSA-SHA384" - "ECDSA-SHA512" # added — required by P-521 - "ED25519" ``` :::tip Hortval tells you before your clients do Any entry you wrote that cannot take effect is reported at startup **and** by `hortval validate`, naming the policy and what to add: ``` issuance policy "corp": allowed-ec-curves lists P-521, but allowed-algorithms does not allow ECDSA-SHA512 — the only algorithm that curve can be used with. No certificate on that curve can be issued. Add ECDSA-SHA512 to allowed-algorithms, or drop the curve ``` Startup also logs the rules in force for each policy, so you can check what a narrowed configuration ended up accepting. A policy that can issue **nothing** is refused outright at startup rather than failing every order at finalize. ::: ## Multiple Policies You can define multiple issuance policies for different environments or certificate types: ```yaml issuance-policies: - name: corp-servers dns-validation-profile: internal dns: allow: - ".corp.internal/3" - name: dmz-servers dns-validation-profile: dmz dns: allow: - ".dmz.example.com/2" ``` When multiple policies exist, you must define explicit [policy bindings](./policy-bindings.md). --- # configuration/license.md --- sidebar_position: 10 title: License --- # License Configuration The `license` section controls optional online checks and auto-renew behavior. License activation is done with a CLI subcommand — either `hortval license register ` (e.g. `CRT-…`) for online registration, or `hortval license install ` to import a `.lic` file. On first start the server prints its **installation key** (`INST-…`) in the logs; you'll need it to download a `.lic` from the portal. See [Getting Started / License](../getting-started/license) for the full flow. ## Configuration ```yaml license: offline: false proxy-url: "http://proxy.corp.local:3128" timeout: 8s ``` ## Fields | Field | Default | Description | |---|---|---| | `offline` | `false` | If `true`, disables online check/renew and runs in offline-only mode | | `proxy-url` | empty | Optional explicit HTTP/HTTPS proxy URL for online license calls | | `timeout` | `30s` | HTTP timeout per online request | ## Online Mode (Default) Online mode is active when `offline` is not set (or set to `false`). Behavior: - startup always validates license offline first (signature + expiry) - background online checks run with adaptive cadence: - `> 30` days before expiry: every 30 days - `<= 30` days before expiry: every 24h - after failed online attempt: retry in 6h (or 1h near expiry) - if the backend returns a renewed `.lic`, Hortval stores it in DB automatically - if backend is unreachable, Hortval keeps running from offline validation - only explicit revocation response from backend is a hard failure The backend base URL is fixed to Hortval's official endpoint in customer-facing deployments. ## Offline Mode (Air-Gapped) Set `offline: true`: ```yaml license: offline: true ``` In offline mode: - no outbound license HTTP calls are made - startup/runtime rely only on the locally stored license in DB - renewal is manual: import a new file with `hortval license install` :::note `hortval license register` requires online access and does not work when `offline: true`. Use `hortval license install` for air-gapped environments. ::: --- # configuration/overview.md --- sidebar_position: 1 title: Overview --- # Configuration Overview Hortval is configured with a single YAML file. The parser is strict: unknown fields, malformed YAML, and missing required relationships all cause startup to fail with an explicit error. ## Top-Level Sections | Section | Required | Description | |---|---|---| | [`server`](./server) | Yes | ACME HTTP server settings | | [`tls-certificate-manager`](./tls) | Yes | TLS certificate for the ACME endpoint | | [`dns-validation-profiles`](./dns-profiles) | Yes | DNS challenge validation settings | | [`authorities`](./authorities) | Yes | ADCS or fake PKI backends | | [`issuance-policies`](./issuance-policies) | Yes | Which names are allowed, key requirements | | [`policy-bindings`](./policy-bindings) | Conditional | Links policies to authorities | | [`database`](./database) | No | Database driver and connection settings | | [`license`](./license) | No | Optional online license checks and auto-renew | | [`logs`](../administration/logging) | No | Log level, format, output, per-service levels | | [`workers`](./workers) | No | Async job engine tuning | | [`rate-limiting`](./rate-limiting) | No | Per-IP, per-account, and duplicate-certificate rate limits | | [`renewal-info`](./renewal-info) | No | ACME Renewal Information (RFC 9773) — suggested renewal window | | [`audit`](../administration/audit) | No | Tamper-evident audit log (HMAC-chained JSONL) | | `workdir` | **Yes** | Base directory for runtime files — absolute, no default | ## Runtime Model The configuration expresses a **policy pipeline**: ``` Incoming CSR │ ▼ issuance-policy ← selects allowed DNS scope and key requirements │ ├── dns-validation-profile ← controls how challenge DNS is resolved │ └── policy-binding ← selects which authority handles issuance │ ▼ authority ← ADCS or fake PKI ``` At runtime: 1. An issuance policy is selected based on the requested identifiers and CSR 2. The policy's DNS validation profile is used to validate challenges 3. On finalize, the policy binding selects an authority (with failover or round-robin) 4. The authority submits the CSR to ADCS ## Implicit Defaults Hortval avoids requiring explicit configuration for common cases: - If `database` is omitted → SQLite at `%WORKDIR%/db.sqlite` - If `license` is omitted → online license mode with defaults (`api.hortval.com`, `30s`) - If `license.offline: true` → offline license mode - If `workers` is omitted → 16 workers with sensible backoff settings - If only one DNS profile exists → policies don't need to reference it explicitly - If exactly one policy and one authority exist → `policy-bindings` can be omitted entirely - If `rate-limiting` is omitted → safe defaults: 200 req/min/IP, 5 accounts/h/IP, 20 orders/h/account, 5 same-FQDN issuances per 7 days, 5 failed validations per (account, hostname) per hour, 30 in-flight pending authzs per account - If `renewal-info` is omitted → ARI is still active with default window (last third of cert lifetime, 48h wide, 6h `Retry-After`) - If `audit` is omitted → the tamper-evident audit log is enabled and writes to `/audit.log` with no in-process rotation (rotation delegated to the OS) ## `workdir` ```yaml workdir: 'C:\ProgramData\hortval' ``` Base directory for all runtime files: SQLite database, TLS certificate cache, log files (when `output: file`). :::warning Required, and absolute. There is no default. A configuration that does not set `workdir` is refused at startup, and by `hortval validate`. Earlier releases picked a location for you; this one does not. **Why.** Every other path setting in this file already refuses to be guessed — all eight are rejected unless absolute or anchored (see below). `workdir` was the single exception, and it is the one that decides where the database, the node identity, the audit log and the CA key live. A default for *that* is a location nobody chose, that moves when the product is renamed, and that has to be arbitrated against the location the previous release used. These are the values previous releases used, and they remain the sensible choice — write the one for your platform: | OS | Conventional location | |---|---| | Windows | `%ProgramData%\hortval` | | Linux | `/var/lib/hortval` | | macOS | `~/Library/Application Support/hortval` | **Upgrading? The refusal tells you where your data is.** Until v2, startup also looks at the locations previous releases used — including the pre-rename `certeasy` ones — and, when an installation is there, names it and prints the exact line to paste. If both hold one, it says so and lets you choose. **Nothing is ever moved for you.** That directory holds the database, the node identity, the audit log and the CA key. Copying a database while opening it is not something a startup path should attempt, and a half-copy leaves two installations that both look right. An installation is recognised by its `server_id` marker, which every release since v0.9.0 writes. A directory holding only a `logs/` folder is not one: a service that started once and failed leaves exactly that, and it says nothing about where your data is. ::: ## Every path must be absolute or anchored :::warning Relative paths are refused This corrects the documentation as much as the product. Earlier versions of this page stated that relative paths in other sections were resolved relative to `workdir`. **They were not** — they were resolved against the process working directory, which for a Windows service created with `sc.exe` is `C:\Windows\System32`, and `sc.exe` offers no field to change it. On SQLite that failed loudly. On PostgreSQL or SQL Server, where the connection string does not depend on `workdir`, it did not: the server started perfectly well on a *second*, empty working directory, with a fresh node identity, a fresh audit chain and a regenerated fake CA. Hortval now refuses them at startup and in `hortval validate`. ::: A path setting is accepted when it is **absolute**, or when it starts with an anchor token: | Token | Expands to | Valid in | |---|---|---| | `%WORKDIR%` | the resolved `workdir` | `database.path`, `audit.path`, `logs.file`, `local-pki-cache-dir`, `letsencrypt.cache-dir`, `local-cert-file`, `local-key-file` | | `%CONFIGDIR%` | the directory holding the configuration file | `workdir` only | ```yaml workdir: "%CONFIGDIR%/workdir" # beside the config file — what `hortval init` writes database: path: "%WORKDIR%/db.sqlite" audit: path: "%WORKDIR%/audit.log" ``` `%CONFIGDIR%` exists for `workdir` alone: it is the one path that could not use `%WORKDIR%`, since it *is* the working directory. Written anywhere else it is refused rather than left as-is — an unsubstituted token would silently create a directory named `%CONFIGDIR%`. Omitting a key entirely is always safe: every default is anchored by construction. --- # configuration/policy-bindings.md --- sidebar_position: 5 title: Policy Bindings --- # Policy Bindings Policy bindings connect **issuance policies** to **authorities**. They define which ADCS backend(s) handle certificate requests for a given policy, and the selection strategy when multiple authorities are available. ## Configuration ```yaml policy-bindings: - policy: corp-server authorities: - ca1 - ca2 strategy: first_available ``` ## Fields | Field | Default | Description | |---|---|---| | `policy` | — | Name of the issuance policy | | `authorities` | — | List of authority names to use for this policy | | `strategy` | `first_available` | Selection strategy when multiple authorities are listed | ## Strategies ### `first_available` Hortval tries the first authority. If it fails (unreachable, error), it moves to the next. This provides **failover**. ```yaml strategy: first_available ``` Use this when you have a primary CA and a backup. ### `round_robin` Hortval distributes requests evenly across all listed authorities. This provides **load balancing**. ```yaml strategy: round_robin ``` Use this when you have multiple equivalent CAs and want to spread load. ## Implicit Binding If `policy-bindings` is omitted entirely and the configuration has **exactly one issuance policy and one authority**, Hortval creates an implicit binding: - policy → the only issuance policy - authorities → the only authority - strategy → `first_available` This simplifies minimal configurations. As soon as you add a second policy or a second authority, you must declare bindings explicitly. ## Multiple Policies Example ```yaml policy-bindings: - policy: corp-servers authorities: - adcs-primary - adcs-backup strategy: first_available - policy: dmz-servers authorities: - adcs-dmz strategy: first_available ``` ## Validation Rules At startup, Hortval verifies: - Every issuance policy has exactly one binding - Every authority referenced in a binding exists - No dangling or duplicate bindings --- # configuration/rate-limiting.md --- sidebar_position: 10 title: Rate Limiting --- # Rate Limiting Hortval enforces several rate limits to protect the ACME endpoint from abuse and to prevent runaway clients from issuing thousands of certificates for the same names. All limits are configurable and individually disablable. The block lives at the top level of the configuration file. The defaults below apply when `rate-limiting` is omitted entirely — note the empty `whitelist`: **no IP bypasses rate limits unless you opt in explicitly** (secure by default). ```yaml rate-limiting: whitelist: # Empty — no bypass by default global: enabled: true requests-per-minute: 1200 burst: 100 abuse: enabled: true abuses-before-block: 10 recovery-per-minute: 20 account-creation: enabled: true per-ip-per-hour: 10 burst: 10 order-creation: enabled: true orders-per-account-per-hour: 20 order-burst: 5 san-budget-per-account-per-hour: 100 duplicate-certificate: enabled: true max-per-window: 5 window: 168h failed-validation: enabled: true max-per-window: 5 window: 1h pending-authorizations: enabled: true max: 30 ``` ## How It Works Rate limits are enforced at six layers, in order: 1. **Global per-IP** — token bucket on **every** endpoint, checked before any cryptographic work. 2. **Abuse per-IP** — marks an IP that behaves in a way no conformant client does. 3. **Operation-specific** — tighter caps on account creation (per IP) and order creation (per account). 4. **Duplicate Certificate** — DB-backed defense against repeat issuance for the same FQDN set. 5. **Failed Validation** — in-memory defense against clients with broken DNS / unreachable challenge targets. 6. **Pending Authorizations** — DB-backed cap on in-flight authzs per account. When a limit is hit, the server replies with HTTP 429 (`urn:ietf:params:acme:error:rateLimited`) and a `Retry-After` header. ## Whitelist **Empty by default.** Adding entries explicitly opts an IP or range out of IP-based limits — Hortval never auto-trusts private RFC 1918 ranges or any other network. `whitelist` accepts both single IPs and CIDR ranges: ```yaml rate-limiting: whitelist: - "127.0.0.1" - "10.0.0.0/8" - "2001:db8::/32" ``` Any client whose IP matches a whitelist entry bypasses `global`, `account-creation` **and `order-creation`**. The limits that remain in force are the ones that do not consult the source IP at all: `duplicate-certificate`, `failed-validation` and `pending-authorizations` — all account-scoped and database- or account-keyed. Whitelisting therefore removes the per-IP request and order ceilings, but not the issuance safeguards behind them. Use this sparingly: typical setups don't need a whitelist at all. In particular, **a shared frontend IP is not a reason to whitelist it**. If clients reach Hortval through a reverse proxy, set `trusted-proxies` in the [`server`](./server.md) section instead, so the limiters key on the real client address. Whitelisting the proxy CIDR would exempt *every* client behind it — the aggregate volume stops being throttled, but so does each individual client. Reserve the whitelist for a source you control end to end, such as a monitoring probe. ## Global Per-IP token bucket applied to every ACME endpoint that accepts a connection. | Field | Default | Meaning | |---|---|---| | `enabled` | `true` | Set to `false` to disable the global limiter entirely | | `requests-per-minute` | `1200` | Sustained rate per source IP | | `burst` | `100` | Maximum tokens accumulated when idle | This is a **comfort ceiling**, sized so a legitimate client is never the one it stops. It now applies to every endpoint, including the polling a client does while it waits for validation — issuing a 3-name certificate costs at least 18 requests, and considerably more when DNS propagation is slow. The defaults were `200`/`20` in earlier versions, when the limiter only saw the four entry points. Because it must stay generous, this bucket is not what stops abuse. That is the next one. ## Abuse Per-IP marking, not a request ceiling. An IP that behaves in a way **no conformant client does** is marked, and a marked IP is then refused on *everything* — not merely on further misbehaviour. | Field | Default | Meaning | |---|---|---| | `enabled` | `true` | Set to `false` to disable marking entirely | | `abuses-before-block` | `10` | Abuses tolerated before the IP is marked | | `recovery-per-minute` | `20` | How fast a marked IP recovers | Recovery is gradual rather than a fixed ban: an IP that stops misbehaving is back to normal within roughly thirty seconds, and a client that slips once is never marked at all. A fixed ban would take a misconfigured client out of service with no way to release it. **What marks an IP** Not every mark weighs the same. The unit is one *confirmed* abuse, which is what `abuses-before-block` counts. | Weight | Situation | |---|---| | **1** — confirmed | A JWS whose signature does not verify, or whose envelope is malformed. | | **1** — confirmed | Acting on a resource that exists and belongs to **another account** — authorization, challenge, certificate, account. | | **¼** — suspected | An identifier that does not exist. | The reduced weight is deliberate. A missing resource usually means someone is probing identifiers at random, but not always: a client coming back to a URL whose resource has since been cleaned up gets the same answer. Rather than try to tell the two apart, the server makes the distinction unnecessary — a handful of misses costs almost nothing, while systematic probing still blocks (four misses make one abuse). A dangling internal reference — the resource exists but what it points to does not — marks **nothing at all**. That is our data being inconsistent, not the client misbehaving. **What deliberately does not** - `badNonce`. It is routine: restarting the server invalidates every nonce in flight, and RFC 8555 requires clients to fetch a new one and retry. Counting it would throttle every client after each restart. - An expired order or authorization, and any refusal that comes from your licence — those are not the client's doing. - Policy refusals such as a rejected identifier or a rejected CSR. A correctly written but misconfigured client produces them repeatedly; they have their own limiters. Marking is per address, so clients sharing one egress address share the mark: a single misbehaving client can have the others refused with it. Where that address belongs to a reverse proxy, `trusted-proxies` resolves it properly by exposing the real client addresses. Where it is genuine NAT and the real addresses are unrecoverable, weigh disabling `abuse` against whitelisting the address — both give up the protection for that whole population, so the choice is which of the two limiters you keep. ## Account Creation Per-IP token bucket applied at `new-account`. Prevents an IP from registering an unbounded number of accounts. | Field | Default | Meaning | |---|---|---| | `enabled` | `true` | | | `per-ip-per-hour` | `10` | Sustained rate of new accounts per IP | | `burst` | `10` | Accounts that may be registered back to back | The burst deliberately **equals** the hourly allowance. Creating an account is a once-per-machine-for-life event — the client keeps its account key — so legitimate traffic arrives in deployment waves with long silences between, not at a steady rate. A smaller burst would spread out a budget you should be free to spend at once, and would refuse the third machine of a batch provisioned together behind one NAT egress address. What this bucket really bounds is not the cost of an account — that is one row — but the fact that an account **multiplies every per-account quota** below it. If you provision more than ten machines at a time behind a single address, raise both values together. ## Order Creation Per-account, with two **independent** quotas: | Field | Default | Meaning | |---|---|---| | `enabled` | `true` | | | `orders-per-account-per-hour` | `20` | Order count quota — 1 token per order, regardless of size | | `order-burst` | `5` | Initial burst on the order count quota | | `san-budget-per-account-per-hour` | `100` | SAN budget — N tokens per N-SAN order | The two quotas are independent: a multi-SAN order consumes more SAN budget but does not consume more burst on the order count. This lets a client issue a small number of large orders OR a larger number of small orders, but not both unboundedly. ## Duplicate Certificate Anti-runaway defense, **DB-backed**. Counts non-revoked certificates issued to an account for the same canonical FQDN set within a rolling time window. Targeted at the most damaging failure mode: a misconfigured client looping on the same domain. | Field | Default | Meaning | |---|---|---| | `enabled` | `true` | | | `max-per-window` | `5` | Maximum issuances per (account, FQDN set) per window | | `window` | `168h` (7 days) | Rolling window for the count | ### How the FQDN set is canonicalised Identifiers in the `newOrder` request are: 1. Lowercased 2. Trimmed of trailing dots 3. Validated as DNS names (LDH form) 4. Deduplicated 5. Sorted Wildcards are preserved (`*.example.com` ≠ `example.com`). The canonical list is hashed with SHA-256 and stored on the order; subsequent orders comparing the same hash count toward the limit. ### Revoked certificates are excluded Revoking a certificate frees a quota slot. This lets an operator legitimately re-issue after a key compromise without being locked out. ### Retry-After is precise When the limit is hit, `Retry-After` is computed from the oldest in-window certificate: once it falls out of the rolling window, one slot frees up. Clients that respect `Retry-After` will wake up exactly when issuance becomes possible again, not earlier. ### When to disable The duplicate-certificate limit is the primary protection against the "2000 certs for one site" scenario. Disabling it is reasonable only if: - You operate a fully internal PKI with trusted, well-behaved clients - You have alternative monitoring (e.g. cert volume alerts) in place Disable it by setting `enabled: false`. ## Failed Validation In-memory token bucket per `(account, hostname)`. Counts challenge failures (challenge transitioning to `invalid`) and refuses new authorizations for that pair once the cap is hit. Targets misconfigured clients with broken DNS, unreachable port 80, or wrong TLS-ALPN setup — without this, such clients endlessly retry and burn worker capacity. | Field | Default | Meaning | |---|---|---| | `enabled` | `true` | | | `max-per-window` | `5` | Maximum failed validations per (account, hostname) per window | | `window` | `1h` | Rolling window over which failures decay | ### How it works - A challenge transition to `invalid` records one failure. - The next `newOrder` request for the same hostname checks the counter: - If under the cap → order created normally. - If at the cap → HTTP 429 with `Retry-After` set to the time until at least one slot frees up. - Counters live in memory only — they are lost on restart, which is fine: a misconfigured client that survives a restart will rediscover its broken setup within a few seconds and the counter will refill. - Wildcards are separate from the base name (`*.example.com` and `example.com` have independent counters). ### Why in-memory and not DB-backed The window is short (1h) and the goal is to short-circuit live abuse, not to enforce a long-term quota. Tracking in memory avoids DB writes on the hot failure path; an in-memory miss after a restart costs at most one extra burst of failures before the counter rebuilds. ### Implementation note The counter increment runs **outside the database transaction** that marks the challenge invalid (via a `PostCommit` hook on the job effect). This avoids extending the SQLite write-lock duration with non-DB work. ## Pending Authorizations Caps the number of **in-flight** pending authorizations per account. An "in-flight" authz is one whose row in `acme_authorizations` has `status='pending'` AND has not yet expired. Targets clients that create orders without ever finalizing them — abandoned orders accumulate authz rows and waste storage. | Field | Default | Meaning | |---|---|---| | `enabled` | `true` | | | `max` | `30` | Maximum in-flight pending authzs per account | ### Why 30 by default The Hortval deployment model is typically **one machine = one ACME account**. A single host issuing certificates for its own domains rarely has more than 5–10 pending authzs at once. 30 is generous for legitimate workflows and tight enough to catch runaway loops. For multi-tenant deployments where one account fronts many machines, raise the cap explicitly. ### Why expired authzs are excluded Hortval does not auto-purge expired authzs from the database (they remain visible for audit). Counting them would mean an account that abandons a few orders gets locked out **permanently**. The check uses `expires_at IS NULL OR expires_at > now()` to count only rows that are actually still in flight. ### Retry-After If the cap is hit, `Retry-After` is the time until the soonest-expiring pending authz drops out of the count. The client wakes up exactly when one slot frees up. ## Tuning Recommendations | Scenario | Suggested change | |---|---| | Internal PKI, few clients | Increase `requests-per-minute` and `orders-per-account-per-hour`; keep `duplicate-certificate` enabled | | Many short-lived test environments | Lower `duplicate-certificate.max-per-window` to `2` to catch loops faster | | Public-facing service | Keep all defaults; do not whitelist anything unless you have a specific reason | | Behind a reverse proxy with shared egress IP | Configure `trusted-proxies` in `server` so client IPs are extracted correctly — the limiter will then key on real client IPs, not the proxy. Avoid whitelisting the proxy CIDR (it would let any client behind the proxy bypass IP limits) | --- # configuration/renewal-info.md --- sidebar_position: 11 title: Renewal Information (ARI) --- # ACME Renewal Information (RFC 9773) Hortval implements [ACME Renewal Information (ARI)](https://datatracker.ietf.org/doc/html/rfc9773), the standard mechanism that lets the server tell ACME clients **when** to renew. Compliant clients (recent certbot, acme.sh, lego, Caddy, Traefik) honour the suggested window instead of relying on their own renewal heuristics. ARI is **always enabled** and exposes one new endpoint: ``` GET /acme/renewal-info/ ``` It is also advertised in the `/directory` response under the `renewalInfo` key. Clients discover the endpoint automatically. ## Why ARI Matters Without ARI, every client picks its own renewal threshold (typically "30 days before expiry"). When ten thousand certificates were all issued on the same day, ten thousand clients renew on the same day, hammering the CA. ARI lets the CA suggest a **per-certificate window** so renewals are spread out across days and the server can also signal an early renewal — for example, after a key compromise. ## Configuration ```yaml renewal-info: lifetime-fraction: 2/3 window-width: 48h retry-after: 6h ``` Omitting this section applies the defaults. `lifetime-fraction` accepts either a decimal (`0.75`) or a fraction (`2/3`, `4/5`). The fraction form is usually clearer: the default really is two thirds, which no decimal writes exactly. | Field | Default | Meaning | |---|---|---| | `lifetime-fraction` | `2/3` | Fraction of the cert lifetime past which the suggested window opens — `2/3` suggests renewal during the last third of the certificate's validity. Accepts a decimal or a fraction; must be strictly between 0 and 1. | | `window-width` | `48h` | Width of the suggested window. Spreads renewals over this interval to avoid thundering-herd reissue. | | `retry-after` | `6h` | Sent as the HTTP `Retry-After` header — tells clients how long to wait before polling `renewal-info` again. | There is no `enabled` flag. ARI is a recovery tool: a client cannot ignore renewal-info if you also need it to react to revocation. Disabling it would only encourage clients to fall back to their own heuristics, defeating the purpose. ## How the Window Is Computed For a non-revoked certificate: ``` lifetime = notAfter − notBefore start = notBefore + lifetime × lifetime-fraction end = min(start + window-width, notAfter) ``` For a **revoked** certificate, the window collapses to `[now, now]`. Compliant clients renew immediately. This makes revocation an effective rollover mechanism — operators no longer need to coordinate manual reissuance after a key compromise or template change. For a degenerate (zero-lifetime) certificate, the window is also `[now, now]`. ## Endpoint Details | Property | Value | |---|---| | Method | `GET` (no JWS) | | Path | `/acme/renewal-info/` | | Auth | None (public) | | Rate limit | Global per-IP only — see [Rate Limiting](./rate-limiting) | | Body format | RFC 9773 §4.2: `suggestedWindow.start` and `suggestedWindow.end` as RFC 3339 timestamps | | Response headers | `Retry-After: ` | The `certID` path parameter is the [RFC 9773 §4.1](https://datatracker.ietf.org/doc/html/rfc9773#section-4.1) format: ``` base64url(AKI) "." base64url(serialNumber) ``` Both components use unpadded base64url. AKI is the Authority Key Identifier extension of the leaf certificate; serial number is the cert's serial as unsigned big-endian bytes. ### Example response ```json { "suggestedWindow": { "start": "2026-08-15T00:00:00Z", "end": "2026-08-17T00:00:00Z" } } ``` ## Lookup Storage Each issued certificate is stored with its `aki` and `serial` (lowercase hex) in `acme_certificates`, with a composite index `(aki, serial)`. Lookup is O(log n). ## Compatibility Notes - Certificates issued by an authority that does not include an Authority Key Identifier extension are not addressable via ARI. The `renewal-info` endpoint will return 404 for them. Configure your ADCS template to include AKI (this is the default in modern ADCS). - Older ACME clients that do not implement ARI continue to work normally — the `directory.renewalInfo` field is simply ignored by them. --- # configuration/server.md --- sidebar_position: 7 title: Server --- # Server The `server` section configures the ACME HTTP endpoint — the address Hortval listens on and the public URL exposed to ACME clients. ## Configuration ```yaml server: listen: ":8443" url: - "https://acme.corp.internal" read-header-timeout: 5s read-timeout: 10s write-timeout: 30s idle-timeout: 60s max-body-bytes: 1048576 shutdown-timeout: 30s remote-ip-header: "X-Forwarded-For" trusted-proxies: - "10.0.0.0/8" ``` ## Fields | Field | Default | Required | Description | |---|---|---|---| | `url` | — | Yes | Public URL(s) ACME clients use to reach Hortval. Used to build all ACME directory links. | | `listen` | `0.0.0.0:8443` | Recommended | Address and port to listen on. | | `read-header-timeout` | `5s` | No | Timeout for reading request headers. | | `read-timeout` | `10s` | No | Timeout for reading the full request body. | | `write-timeout` | `30s` | No | Timeout for writing the response. | | `idle-timeout` | `60s` | No | Keep-alive idle connection timeout. | | `max-body-bytes` | `1048576` (1 MB) | No | Maximum request body size. | | `shutdown-timeout` | `30s` | No | Graceful shutdown wait time. Must be ≤ `workers.drain-timeout`. See [Graceful shutdown](../administration/shutdown.md). | | `remote-ip-header` | — | No | Header to trust for the client IP (e.g. `X-Forwarded-For`). Only used if `trusted-proxies` is set. | | `trusted-proxies` | — | No | CIDR ranges of trusted reverse proxies. | ## Notes ### `server.url` `url` is mandatory. It must match the hostname that ACME clients will use to reach Hortval. Hortval embeds this URL in the ACME directory response and in all object links (orders, authorizations, challenges). If you are behind a reverse proxy, set `url` to the public hostname, not the internal listen address. ```yaml server: listen: ":8443" # internal bind address url: - "https://acme.corp.internal" # public URL clients use ``` ### Behind a Reverse Proxy If Hortval sits behind a reverse proxy (nginx, Caddy, IIS ARR…), set `remote-ip-header` and `trusted-proxies` to preserve the original client IP in logs and audit records: ```yaml server: remote-ip-header: "X-Forwarded-For" trusted-proxies: - "10.0.0.0/8" ``` Only proxies in `trusted-proxies` are allowed to set the `remote-ip-header`. Requests from untrusted IPs ignore the header. --- # configuration/tls.md --- sidebar_position: 8 title: TLS Certificate Manager --- # TLS Certificate Manager The `tls-certificate-manager` section configures the TLS certificate that Hortval uses for its **own HTTPS endpoint** — not the certificates it issues to clients. Every hostname listed in `server.url` must be covered by exactly one bundle, or the server will not start. ## Configuration ```yaml tls-certificate-manager: bundles: - name: public hosts: - "acme.corp.internal" mode: files local-cert-file: "C:\\hortval\\tls\\fullchain.pem" local-key-file: "C:\\hortval\\tls\\privkey.pem" file-watch-interval: 5s ``` ## Bundles A bundle associates a set of hostnames with a TLS certificate source. At least one bundle is required. For an external name you can use a Let's Encrypt certificate; for an internal name you can use your ADCS certificate. ### Common fields | Field | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | Bundle identifier | | `hosts` | list of strings | Conditional | Hostnames this bundle serves. Can be omitted if there is only one bundle. | | `mode` | string | Yes | Certificate source: `files`, `pki`, or `letsencrypt` (beta) | ### `files` mode fields | Field | Type | Required | Description | |---|---|---|---| | `local-cert-file` | string | Yes | Path to the PEM certificate chain | | `local-key-file` | string | Yes | Path to the PEM private key | ### `pki` mode fields | Field | Type | Required | Description | |---|---|---|---| | `authority` | string | Yes | Name of the authority to use for auto-issuance and renewal | | `key` | object | No | Key algorithm/size for the generated CSR. Defaults to ECDSA P-256. See [Key type](#key-type). | ## Modes ### `files` — Static Files Hortval reads the certificate and key from disk. Use this when you manage the server certificate externally (e.g. via another ACME client or manual renewal). ```yaml bundles: - name: public mode: files local-cert-file: "C:\\hortval\\tls\\fullchain.pem" local-key-file: "C:\\hortval\\tls\\privkey.pem" ``` Hortval watches the files for changes and reloads automatically (controlled by `file-watch-interval`). | Field | Default | Description | |---|---|---| | `file-watch-interval` | `5s` | How often to check for certificate file changes | ### `pki` — Auto-renewal via Internal PKI Hortval issues and renews its own server certificate through one of its configured authorities. The certificate is cached locally. ```yaml bundles: - name: public mode: pki authority: ca1 ``` This is the recommended mode for fully automated certificate management. | Field | Default | Description | |---|---|---| | `acquire-timeout` | `2m` | Timeout to acquire a certificate at startup | | `renew-before` | `720h` (30 days) | How early to start renewal before expiry | | `pki-poll-interval` | `2s` | Polling interval when waiting for PKI issuance | | `local-pki-cache-dir` | `%WORKDIR%/server-certificate-cache` | Directory to cache PKI-issued server certificates | #### Key type By default Hortval generates an **ECDSA P-256** key for its own server certificate. If the backing CA rejects that key — most commonly an **ADCS certificate template that mandates RSA** (e.g. minimum key size 4096, RSA provider only) — set an explicit `key:` on the bundle so the generated CSR matches what the template requires: ```yaml bundles: - name: public mode: pki authority: ca1 key: type: rsa # "ecdsa" (default) or "rsa" size: 4096 # RSA only: modulus bits (default 3072) # For ECDSA use `curve:` instead of `size:`, e.g.: # type: ecdsa # curve: P-384 # P-256 (default) | P-384 | P-521 ``` `size` and `curve` are mutually exclusive: `size` applies to RSA, `curve` to ECDSA. The curve names match `allowed-ec-curves` in issuance policies. | Field | Values | Default | Description | |---|---|---|---| | `key.type` | `ecdsa`, `rsa` | `ecdsa` | Key algorithm for the generated CSR | | `key.size` | `2048`–`8192` | `3072` | RSA modulus bits (RSA only) | | `key.curve` | `P-256`, `P-384`, `P-521` | `P-256` | ECDSA curve (ECDSA only) | :::note If the CA rejects the key type, issuance of the server certificate fails and Hortval does not start. With an RSA-only ADCS template you will see the CA deny the request (`CERTSRV_E_KEY_LENGTH`) unless the bundle sets `key.type` to `rsa`: ```yaml key: type: rsa size: 4096 ``` See [ADCS authorities](./adcs.md). ::: ### `letsencrypt` — Public CA (Let's Encrypt) — beta :::warning Beta Let's Encrypt mode works but is not yet part of the formally supported release surface. It also exposes a component of your PKI on the public internet — read the trade-off below before choosing it. In most deployments `pki` or `files` is the better answer. ::: #### Why a public certificate — and what HTTP-01 costs you **The value.** Every ACME client already trusts Let's Encrypt — it is in `certifi`, in curl's bundle, in every OS store. A publicly-trusted certificate on Hortval therefore means **nothing to install and nothing to configure on the client side**: no root to distribute, and no per-client trust-store setting to push, before a machine can so much as talk to your ACME endpoint. That is the whole reason this mode exists, and on a large fleet it removes a real bootstrap problem. **The cost.** HTTP-01 requires the challenge listener to be reachable from the internet on port 80. That puts a component of your PKI on the public network. For many deployments this is not a good architectural trade, and it is worth saying plainly: this mode is offered, not recommended by default. **Prefer, in this order:** 1. **`mode: pki` — an internal certificate from your own CA.** The elegant one: one chain of trust, nothing exposed publicly, no external dependency, no rate limits, and Hortval's own certificate follows the same lifecycle as every certificate it issues. Its cost is real and worth stating, in two parts. Your root has to reach the trust store of every machine that talks to Hortval — and those are the Linux servers, proxies, load balancers and containers that Active Directory does not reach. Group Policy already covers your Windows estate; it does not cover the population Hortval exists for. And the OS trust store is not the end of it: **ACME clients do not all use it**. certbot reads Python's own bundle (`certifi`) and needs `REQUESTS_CA_BUNDLE`; lego reads a single PEM named by `LEGO_CA_CERTIFICATES`; acme.sh goes through curl and needs `--ca-bundle` or `CURL_CA_BUNDLE`. So the root has to be deployed *and* each client pointed at it, on every machine. Both steps are one-time work for your configuration management, and good practice for any internal TLS service — but they are steps, and they are per client. Each client page gives the specifics: [lego](../clients/lego.md), [certbot](../clients/certbot.md), [acme.sh](../clients/acme-sh.md). 2. **DNS-01 with a standalone ACME client, then `mode: files`.** When you want a publicly-trusted certificate — so clients need no internal CA at all, which is exactly the step option 1 asks of you — without exposing anything. Hortval's file watcher reloads the pair when it changes on disk, so renewals are picked up without a restart (`file-watch-interval`, 5s by default). 3. **HTTP-01**, when the endpoint is already internet-facing anyway, or when you accept the exposure knowingly. #### Setting it up For a **publicly resolvable** hostname, Hortval obtains and auto-renews its own HTTPS certificate directly from Let's Encrypt (via the built-in ACME `autocert` client). Set the bundle to `mode: letsencrypt` and enable the manager-level `letsencrypt:` account block; the bundle's `hosts` become the issuance whitelist. ```yaml tls-certificate-manager: bundles: - name: public hosts: - "acme.example.com" # must be publicly resolvable mode: letsencrypt letsencrypt: enabled: true # required when any bundle uses letsencrypt mode email: "pki@example.com" # ACME account / expiry notices http-addr: ":80" # where the HTTP-01 challenge is answered cache-dir: "%WORKDIR%/autocert" ``` The Let's Encrypt Terms of Service are accepted automatically. If a bundle uses `mode: letsencrypt` while `letsencrypt.enabled` is `false`, the server refuses to start. #### If you do use HTTP-01 **It is the only challenge Hortval wires.** DNS-01 and TLS-ALPN-01 are not, and no setting turns them on — for those, use option 2 above. The listener is then the only thing you expose: `server.listen` can stay on an internal interface, because Let's Encrypt only ever talks to the challenge listener. - It serves `/.well-known/acme-challenge/` and returns **404 for every other path** — no redirect, no reflection of the request. - Only the hosts of your `letsencrypt` bundles get an answer there. - `http-addr` accepts a full address, so you can bind it to one interface (`203.0.113.10:80`) and keep `server.listen` on another. :::caution Not available on air-gapped installs This mode needs **outbound** HTTPS to Let's Encrypt — account, orders, issuance — on top of the **inbound** port 80 for the challenge. An air-gapped or egress-filtered deployment cannot use it: choose `mode: pki` (option 1), or issue the certificate on a connected machine and carry it in with `mode: files`. Running disconnected also means running the licence offline — see [Offline Mode (Air-Gapped)](./license.md#offline-mode-air-gapped), which is the constraint to plan around first. ::: :::warning Let's Encrypt validates HTTP-01 on port 80, always Setting `http-addr` to another port (`:8080`) **only works behind a reverse proxy that forwards public port 80 to it**. Let's Encrypt does not follow a different port. Facing the internet directly, the listener must be on `:80`. ::: #### DNS-01 support, by client Support differs, and the difference matters when you pick one for option 2: | Client | DNS-01 | |---|---| | [lego](https://go-acme.github.io/lego/) | Built-in providers, `--dns ` | | [acme.sh](https://github.com/acmesh-official/acme.sh) | Built-in DNS APIs, `--dns dns_` | | [certbot](https://certbot.eff.org/) | Needs a `certbot-dns-*` plugin for your provider, or `--manual` with auth hooks you write | ```yaml tls-certificate-manager: bundles: - name: public hosts: ["acme.example.com"] mode: files # certificate obtained elsewhere, e.g. DNS-01 local-cert-file: "/etc/hortval/fullchain.pem" local-key-file: "/etc/hortval/privkey.pem" ``` :::info One failed authorization per issuance is expected The bundled `autocert` client always attempts `tls-alpn-01` first and cannot be told otherwise. That attempt fails — nothing answers it — and the client then obtains the certificate over HTTP-01. You will see one invalid authorization per issuance in your Let's Encrypt account history. This is well inside Let's Encrypt's limit of 5 failed validations per hostname per hour: a certificate is issued roughly every 60 days per host. ::: | Field | Default | Description | |---|---|---| | `letsencrypt.enabled` | `false` | Master switch; must be `true` when any bundle uses `letsencrypt` mode | | `letsencrypt.email` | — | ACME account email (renewal / expiry notices) | | `letsencrypt.http-addr` | `:80` | Address where the HTTP-01 challenge server listens. Must be reachable on **public port 80** — use another port only behind a reverse proxy | | `letsencrypt.cache-dir` | — | Directory caching issued certificates and the account key (set explicitly, e.g. `%WORKDIR%/autocert`) | ## Multiple Bundles If you serve Hortval on multiple hostnames, define one bundle per hostname group: ```yaml tls-certificate-manager: bundles: - name: internal hosts: - "acme.corp.internal" mode: files local-cert-file: "/etc/hortval/tls/internal.pem" local-key-file: "/etc/hortval/tls/internal.key" - name: dmz hosts: - "acme.dmz.example.com" mode: files local-cert-file: "/etc/hortval/tls/dmz.pem" local-key-file: "/etc/hortval/tls/dmz.key" ``` --- # configuration/workers.md --- sidebar_position: 9 title: Workers --- # Workers The `workers` section configures the **async job engine** that runs challenge validation and certificate issuance in the background. ## Configuration ```yaml workers: worker-id: "worker-1" workers: 16 lease: 30s idle-min: 50ms idle-max: 200ms base-backoff: 1s max-backoff: 2m queue-size: 16 drain-timeout: 30s max-job-duration: 5m ``` ## Fields | Field | Default | Description | |---|---|---| | `worker-id` | `worker` | Unique identifier for this worker instance. Useful in multi-node deployments. | | `workers` | `16` | Number of concurrent worker goroutines. Goroutines are essentially free in Go (~2 KB stack each), so the default is generous; raise it further only if you observe the queue backed up. | | `lease` | `30s` | How long a worker holds a job lock. If processing takes longer, the lease is renewed automatically. | | `idle-min` | `50ms` | Minimum polling interval when the queue is empty. | | `idle-max` | `200ms` | Maximum polling interval when the queue is empty. Caps the empty-queue exponential backoff so the first job that arrives after a long quiet period is picked up within this delay. | | `base-backoff` | `1s` | Initial backoff on job failure. | | `max-backoff` | `2m` | Maximum backoff after repeated failures. | | `queue-size` | value of `workers` | In-memory job queue buffer size. | | `drain-timeout` | `30s` | Maximum graceful-stop wait time for in-flight jobs before forced worker cancellation. Must be ≥ `server.shutdown-timeout`. See [Graceful shutdown](../administration/shutdown.md). | | `max-job-duration` | `lease × 10` (i.e. `5m` at default lease) | Hard cap on a single handler invocation (one `Submit` or one `Check`, **not** the total job lifetime — polling jobs run their handler many times). When it elapses, the handler's context is cancelled (the deadline propagates through every ctx-aware network/command call), the heartbeat stops renewing the lease, and the job is requeued or failed depending on `max-attempts`. It is a wedge-recovery backstop for a handler stuck on a dead socket or deadlocked syscall, **not** a per-job SLA — keep it well above the longest legitimate invocation. Must exceed `lease`. Set to a negative value to disable. | ## How the Job Engine Works All background work in Hortval (DNS challenge validation, ADCS polling) is handled by the job engine: 1. An ACME handler enqueues a job in the database 2. A worker picks up the job and acquires a lease 3. The worker executes the job handler (validate DNS, poll ADCS…) 4. On success, the job is marked complete 5. On transient failure, the job is rescheduled with exponential backoff 6. On fatal failure, the job is failed and the associated order is invalidated Jobs are persistent — if Hortval restarts mid-processing, workers resume from the database. ## Shutdown and Recovery - On **graceful stop** (`SIGTERM`), the dispatcher stops claiming new jobs, then workers drain in-flight jobs for up to `drain-timeout`. - If `drain-timeout` is exceeded, in-flight handlers are cancelled and process shutdown continues. - On **force kill** (`SIGKILL` / `kill -9`), no graceful cleanup runs. In-flight jobs remain locked until their lease expires, then are picked again by workers after restart. - In practice, worst-case recovery delay after force kill is approximately `lease`. ## Tuning The default settings (16 workers, 1s–2m backoff) work well for most deployments. Consider adjusting if: - **High certificate volume**: increase `workers` and `queue-size` - **Slow ADCS**: increase `max-backoff` and `lease` to tolerate longer processing times - **Multi-node**: set a unique `worker-id` per instance to distinguish workers in logs - **Many idle instances against a shared database (HA)**: raise `idle-max` to `1s`–`2s` to reduce the steady-state read load on the shared database. The defaults are tuned for a single-instance deployment, where the per-poll cost is negligible and tight polling keeps certificate-issuance latency low. ## Tuning Relationships - Set `drain-timeout` to cover normal in-flight processing time during maintenance restarts. - Keep `lease` long enough to avoid premature reclaim during transient slowdowns, while still allowing acceptable post-crash recovery time. - In orchestrators, configure termination grace period to be greater than both `server.shutdown-timeout` and `workers.drain-timeout` (plus margin). ## Multi-node Deployments Running multiple Hortval instances against the same database is supported (PostgreSQL, SQL Server). Each instance competes for job leases — only one instance processes each job. Set `worker-id` to a unique value per instance: ```yaml # Node 1 workers: worker-id: "worker-node1" ``` ```yaml # Node 2 workers: worker-id: "worker-node2" ``` --- # getting-started/first-certificate.md --- sidebar_position: 5 title: First certificate --- # First Certificate Goal: prove your Hortval instance works end-to-end by issuing one certificate. We use **certbot** with **HTTP-01** because it's the safest "out of the box" combination — certbot (2.x and later) defaults to an **ECDSA P-256** key and a `serverAuth`-only EKU, which line up with Hortval's default policy without any tuning. (On an older certbot that still defaults to RSA 2048, add `--key-type ecdsa` — RSA 2048 is below the default `min-rsa-bits: 3072` and would be refused.) If you already know which client you want to use, skip straight to its fiche: | Client | Best fit | Fiche | |---|---|---| | **certbot** | Linux servers, distro packages, widest docs | [ACME Clients → certbot](../clients/certbot.md) | | **lego** | Containers / CI, static binary, ECDSA default | [ACME Clients → lego](../clients/lego.md) | | **acme.sh** | Minimal systems, pure shell+curl, no Python/Go | [ACME Clients → acme.sh](../clients/acme-sh.md) | Each fiche covers HTTP-01 (standalone + webroot), DNS-01, renewal and revocation for that specific client, plus the gotchas (key types, trust store, EKU…). ## Prerequisites - Hortval running and accessible at `https://acme.corp.internal` - certbot installed on the target machine - Port **80** reachable on the target machine from Hortval's configured DNS resolver - The target machine's DNS name is allowed by your issuance policy - Your internal CA deployed to the OS trust store (see [Trusting your internal CA](../clients/certbot.md#trusting-your-internal-ca) on the certbot fiche) ## Issue your first certificate ```bash export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt # adapt for RHEL certbot certonly \ --standalone \ --preferred-challenges http \ --server https://acme.corp.internal/acme/directory \ -d app.corp.internal ``` What happens: 1. certbot opens port 80 inside its process. 2. certbot POSTs an order to Hortval. 3. Hortval fetches `http://app.corp.internal/.well-known/acme-challenge/` and verifies the response. 4. Hortval forwards the CSR to your back-end CA (ADCS or fakepki). 5. certbot writes the issued certificate under `/etc/letsencrypt/live/app.corp.internal/`. ## Check the result ```bash openssl s_client -connect app.corp.internal:443 -showcerts ``` You should see a certificate issued by your internal CA. ## Next steps - **Automate renewal**: `systemctl enable --now certbot.timer`. Full details on the [certbot fiche → Renewal](../clients/certbot.md#renewal). - **Wildcards**: HTTP-01 cannot validate `*.corp.internal`. Switch to DNS-01 — see [DNS-01 on the certbot fiche](../clients/certbot.md#dns-01-for-wildcards). - **Switch clients**: [lego](../clients/lego.md) for containers/CI, [acme.sh](../clients/acme-sh.md) for minimal systems. ## Troubleshooting If this minimal scenario fails, the [certbot fiche](../clients/certbot.md) has the comprehensive coverage. Three most common causes on a fresh setup: - **TLS handshake fails before any cert request**: the internal CA isn't in the OS trust store, and `REQUESTS_CA_BUNDLE` isn't set. Re-read [Trusting your internal CA](../clients/certbot.md#trusting-your-internal-ca). - **Challenge validation fails**: port 80 isn't reachable from Hortval's host, or certbot can't bind it (run as root or grant `cap_net_bind_service`). - **DNS name rejected**: the target FQDN is outside the allow list of your [issuance policy](../configuration/issuance-policies.md). --- # getting-started/installation.md --- sidebar_position: 1 title: Installation --- # Installation Bringing Hortval online is a three-step process: 1. **Install the binary** on the host — download, place, create the work directory. 2. **Configure and activate** — the fastest path is [`hortval init`](wizard.md), an interactive wizard that asks a few questions, writes a valid configuration, and offers to open a cold-start window or install your license on the spot. If you prefer to write the YAML yourself, follow [Minimal configuration](minimal-configuration.md) + [License](license.md). 3. **Deploy as a long-running service** — Windows service or `systemd` unit so the binary survives reboots and restarts. This page covers steps 1 and 3. Step 2 lives in the dedicated pages further in this section. ## Requirements | Requirement | Detail | |---|---| | **OS** | Windows Server 2016+ (production), Linux (dev/test) | | **ADCS** | Active Directory Certificate Services, accessible from the Hortval host | | **certreq.exe** | Not required by the default native connector. Only needed if you select the `adcs-cli` connector (ships with Windows). | | **Network** | Hortval must be reachable by ACME clients (HTTPS, port 443 or custom) | | **Database** | SQLite (default, no setup), PostgreSQL, or SQL Server | :::warning Deployment topology Hortval is supported as a **single-instance** deployment, or as **cold Active / Passive** with manual switchover (PostgreSQL or SQL Server required, no SQLite). Running two Hortval instances concurrently against the same database is **not supported** and produces silent failure modes (`badNonce` errors, drifting rate limits, etc.). See [Deployment topology](../administration/deployment-topology.md) before deploying. ::: ## Step 1 — Install the binary ### Download Download the latest release from the [releases page](https://github.com/hortval/hortval/releases). Each release ships three binaries — `hortval--linux-amd64`, `hortval--darwin-arm64`, and `hortval--windows-amd64.exe`. The Windows binary is a single executable, no installer or runtime dependencies. :::tip Verify your download Each release ships a `SHA256SUMS` file. Verify the integrity of the binary before running it — see [Verifying release binaries](../security/verifying-binaries.md). ::: ### Work directory Hortval uses a **work directory** for runtime files (SQLite database, TLS cache, logs, identifiers). The default locations are: - **Windows**: `%ProgramData%\hortval` - **Linux**: `/var/lib/hortval` Create the directory and make sure Hortval's service account has write access. ```powershell # Windows New-Item -ItemType Directory -Path "C:\ProgramData\hortval" ``` ```bash # Linux mkdir -p /var/lib/hortval ``` At this point the binary is in place but the server is **not** running yet — you need a configuration file and an activated license before `hortval serve` will accept to start. The next pages walk you through both. ## Step 2 — Configure and activate The recommended path is: 1. [**Quick start with the wizard**](./wizard.md) — `hortval init` produces a valid configuration and offers to either open a cold-start window or install / register your license. 2. [**First certificate**](./first-certificate.md) — verify the end-to-end flow with an ACME client. If you'd rather assemble the YAML by hand, the same content is covered in [Minimal configuration](./minimal-configuration.md) and [License](./license.md). Once `hortval serve` runs cleanly and the first ACME client has obtained a certificate, come back to **Step 3** below to productize. ## Step 3 — Deploy as a long-running service In production you do not want `hortval serve` running from an interactive shell — it must restart with the host, survive operator sessions, and log to a managed sink. Wrap the binary in a service unit. ### Windows service Run Hortval as a Windows service using `sc.exe` or [NSSM](https://nssm.cc/), under a **dedicated service account**. :::danger Hortval does not run as a Windows service yet — this ships in v0.9.6 **v0.9.5 cannot be started by the Service Control Manager.** Hortval does not implement the SCM handshake, so a service created with `sc.exe` fails to start with **error 1053** — "the service did not respond to the start request in a timely fashion". The process does run for about thirty seconds before the SCM kills it, which leaves the shutdown drain unfinished and `db.sqlite-wal` / `db.sqlite-shm` files behind. This is a known limitation of the first release, not a configuration mistake: nothing you change in `config.yml` or in the `sc.exe` line will fix it. Native service support — SCM handshake, Windows event log, and a `hortval diag` subcommand to prove where the logs went — is the headline of **v0.9.6**. **Until then**, run `hortval serve -f ` in a console, or under a wrapper that performs the SCM handshake on the binary's behalf — a scheduled task, or NSSM. Neither wrapper has been validated against Hortval yet. Whichever you choose, **capture stderr**. A handful of startup lines are written there and never reach `logs.file`, and when startup is *refused* the log file is not created at all — everything goes to stderr. Under the Windows SCM stderr is attached to nothing, so those lines are lost outright. Under NSSM, set `AppStderr`. The procedure below is the target shape and is correct for the account model — only the start step is affected. ::: :::caution Do not run the service as LocalSystem `sc.exe create` without `obj=` gives you LocalSystem, the highest local privilege. Hortval listens on the network and holds the enrollment identity for your CA, so that account is the wrong place for it. Give it an account whose only privilege on the PKI is the one it needs. ::: Enroll permission is granted in Active Directory, so the account has to be a domain account. A **group Managed Service Account (gMSA)** is the best option: Windows rotates its password and it is never typed anywhere. ```powershell # Once, on a domain controller New-ADServiceAccount -Name hortval -DNSHostName hortval.example.com ` -PrincipalsAllowedToRetrieveManagedPassword "HORTVAL-HOST$" # On the Hortval host Install-ADServiceAccount -Identity hortval ``` Create the service under it — the trailing `$` and the empty password are how a gMSA is declared: ```powershell sc.exe create Hortval ` binPath= "C:\hortval\hortval.exe -f C:\hortval\config.yml" ` obj= "EXAMPLE\hortval$" password= "" start= auto sc.exe description Hortval "ACME server for internal ADCS" sc.exe start Hortval ``` Where gMSA is not available, use an ordinary domain account dedicated to Hortval — `obj= "EXAMPLE\svc-hortval" password= "..."` — with its password held in your secret store. Grant that account, and nothing beyond: - **Log on as a service** (`SeServiceLogonRight`) — the service will not start without it - Write access to the work directory - Enroll permission on the ADCS certificate template - Network access to the ADCS host - (only with the `adcs-cli` connector) Read and execute on `certreq.exe` and `certutil.exe` in the Windows system directory Reusing an existing administrative account defeats the point: enrollment on one template is the only right Hortval needs on your PKI, and whatever else the account carries is available to anything that reaches the service. ### Linux (systemd) Create a unit file under `/etc/systemd/system/hortval.service`: ```ini [Unit] Description=Hortval ACME server After=network-online.target Wants=network-online.target [Service] Type=simple ExecStart=/usr/local/bin/hortval -f /etc/hortval/config.yml Restart=on-failure RestartSec=5s User=hortval Group=hortval # Hardening — adjust to your environment NoNewPrivileges=true ProtectSystem=strict ReadWritePaths=/var/lib/hortval ProtectHome=true [Install] WantedBy=multi-user.target ``` Then enable and start: ```bash sudo systemctl daemon-reload sudo systemctl enable --now hortval sudo journalctl -u hortval -f ``` :::info Linux without ADCS The Linux binary cannot submit to ADCS — ADCS enrollment is Windows-only (both connectors). For local testing on Linux, use the **fake PKI** authority — see [Configuration / Authorities](../configuration/authorities). ::: ### After deployment - Logs land in the OS log sink (Event Log on Windows, `journalctl` on systemd). See [Logging](../administration/logging.md) for tuning log format and per-service levels. - Make sure your monitoring picks up restarts and license-related warnings — see [License enforcement](../administration/license-enforcement.md) for the events emitted at boot and on every refused order. - Plan for backups of the work directory (database + audit log) — see [Backup and restore](../administration/backup.md). --- # getting-started/license.md --- sidebar_position: 3 title: License --- # License :::tip If you're just getting started Most operators don't need this page in detail — [`hortval init`](wizard.md) walks you through license setup interactively. This page is for fine-grained control or when you need to script the activation yourself. ::: `hortval serve` will not start without an active license **or** an explicit cold-start window. You have three ways to get there from a fresh install — pick the one that matches your situation: | Situation | Use | |---|---| | You have a license key from your account page | [Online registration](#option-1--online-registration) | | You downloaded a `.lic` file (air-gapped or support-issued) | [Manual file import](#option-2--manual-file-import) | | You want to evaluate before purchasing / your license is in flight | [Cold-start](#option-3--cold-start-without-a-license-yet) | Free licenses are issued from [hortval.com/free](https://hortval.com/free). Paid licenses are sent by email after trial/purchase. ## License File Format The `.lic` file is a PEM-encoded text file: ``` -----BEGIN HORTVAL LICENSE----- Signature: -----END HORTVAL LICENSE----- ``` The payload contains your plan, the number of authorized ADCS authorities, and the expiry date. The signature is verified offline against a public key embedded in the binary. :::note You may also receive a `CERTEASY LICENSE` block Licences issued before the rename carry `-----BEGIN CERTEASY LICENSE-----`, and the portal still serves that form by default. **Hortval reads both**, so either file works and there is nothing to convert. Only the older Certeasy binaries are restricted to the legacy block — which is why it remains the default. ::: ## Identifiers Hortval uses two human-readable keys, both in Crockford-base32 with a built-in check digit (no `I`, `L`, `O`, or `U`): | Key | Prefix | Example | Where it comes from | |---|---|---|---| | **License key** | `CRT-` | `CRT-EAYG2Q-QQBYYQ-VZHZ4M-5GWHNJ-V96MQX` | Issued on your account page; pass to `hortval license register` | | **Installation key** | `INST-` | `INST-4RD63B-JE8MKM-MA5R51-DENCSA-52HJ6X` | Generated locally on first start; printed in the logs | Both keys are five groups of six characters; the last character is a checksum (Luhn mod-32 over Crockford-base32). The example values above intentionally end with `X` and **will not validate** — replace them with the real key shown on your account page or printed in your server logs. A mistyped license key is rejected at `hortval license register` time with a clear error message before any network call is made. ## Activation Methods There are three ways to bring Hortval up: online registration, manual file import, or — when no license has been issued yet — a cold-start window. ### Option 1 — Online Registration Register directly from the command line using your license key from [hortval.com/account](https://hortval.com/account). You need: - Your **license key** — available on your account page (shape: `CRT-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX`) - A **deployment environment** label (`prod`, `dev`, `staging`, etc.) ```powershell # Windows hortval.exe license register -f C:\hortval\config.yml --env prod ``` ```bash # Linux ./hortval license register -f /etc/hortval/config.yml --env prod ``` The server name defaults to the machine hostname. Override it with `--env-name`: ```bash ./hortval license register -f /etc/hortval/config.yml --env prod --env-name my-server ``` Behavior of `hortval license register`: - connects to hortval.com and registers the installation - downloads and stores the `.lic` in DB automatically - exits (does not start the ACME server) - `--env` is required; `--env-name` defaults to the machine hostname If this installation is already registered under a different license, the command fails with an error asking you to migrate via the portal. :::note `hortval license register` requires online access to hortval.com. For air-gapped environments, use Option 2. ::: ### Option 2 — Manual File Import Download the `.lic` from [hortval.com/account](https://hortval.com/account) (you will need the installation key — see [Runtime Validation](#runtime-validation) below) and import it: ```powershell # Windows hortval.exe license install -f C:\hortval\config.yml C:\temp\hortval.lic ``` ```bash # Linux ./hortval license install -f /etc/hortval/config.yml /tmp/hortval.lic ``` Behavior of `hortval license install`: - validates signature + expiry - writes the license to DB - exits (does not start the ACME server) If the import fails, the process exits with a non-zero code. ### Option 3 — Cold-start (without a license yet) Use this when no license has been issued yet — typical during evaluation, or while a paid license is being procured. Cold-start opens a **1-week window** during which `hortval serve` runs against the constraints of a plan you choose: ```bash ./hortval cold-start init --plan=pro -f /etc/hortval/config.yml ``` The plan determines the limits that apply (allowed database drivers, maximum authorities, managed-server cap) — pick the one that matches your intended deployment. See [Plans](../intro/plans.md) for the per-plan limits. The window can be extended for another 7 days at a time, capped at a 3-week total once your installation has served any ACME client. After the cap, only installing a real license restores normal boot. See the [Cold-start page](../administration/cold-start.md) for the full action surface (`cold-start init`, `cold-start switch`, `cold-start extend`, `cold-start status`) and the recovery actions if the window elapses. When the license arrives, install it with any of the methods above — the cold-start state is cleared automatically. ## Runtime Validation At startup, Hortval validates the stored license offline (signature + expiry). No internet access is required for this step. If no license is installed and no cold-start window is open, `hortval serve` refuses to start. The startup banner prints your **installation key** and the three available activation paths listed at the top of this page. If a license is expired: - startup is still allowed for 7 days (post-expiry grace) - after that, startup fails with `license has expired` When the binary refuses to start for any license reason, it prints both a structured JSON log and a plain-text banner on stderr listing the recovery actions you can take. If your installed license has expired past its post-expiry grace and you need to keep the binary running while a renewal is in flight, `hortval license force-grace --confirm` opens a 7-day window that boots despite the error. See [License enforcement / Force-grace](../administration/license-enforcement.md#force-grace-one-shot-escape-hatch) for the full semantics. ## Online Checks and Auto-Renew Hortval can optionally run online checks and auto-renew by calling the backend refresh API. Online behavior is configured in `license` (see [Configuration / License](../configuration/license)). Default check cadence: - more than 30 days before expiry: every 30 days - 30 days or less before expiry: every 24h - after a failed online attempt: retry in 6h (or 1h near expiry) If the refresh endpoint is unreachable, Hortval continues with offline validation. Only an explicit server revocation response is a hard failure. During post-expiry startup grace, online renewal can still recover the installation automatically if online checks are enabled. By default, online checks are enabled and target Hortval's official backend. To force offline mode, set: ```yaml license: offline: true ``` ## Manual Renewal / Replacement To manually update a license (air-gapped, support-issued license, etc.), run `hortval license install` again with the new file: ```bash ./hortval license install -f /etc/hortval/config.yml /tmp/new-hortval.lic ``` For immediate effect on a running instance, restart the service after import. ## Checking License Status ```powershell # Windows — tail the Hortval log Get-Content "C:\ProgramData\hortval\hortval.log" -Tail 20 ``` ```bash # Linux tail -20 /var/lib/hortval/hortval.log ``` On startup, Hortval logs license details (`id`, `plan`, holder, every enforcement limit — `max_cas`, `max_managed_servers`, `allowed_dbs`, `active_instances`, `passive_instances` —, expiry, source). Numeric limits are rendered as `unlimited` (the plan grants no cap), `` (the cap), or `FORBIDDEN` (the plan grants no entitlement at all) so missing/zero fields are visible at a glance. ## Troubleshooting **`WARNING: PRODUCT NOT REGISTERED`** No license is stored in the database. The startup logs print your **installation key** (`INST-…`) and the registration URL — use it to activate via `hortval license register ` or download a `.lic` from the portal and import it with `hortval license install`. To start the server before the license is installed, open a cold-start window with `hortval cold-start init --plan=` (see [Cold-start](../administration/cold-start.md)). **`invalid license: invalid license signature`** The provided `.lic` file is corrupted or was modified. **`license has expired`** License is beyond the 7-day post-expiry startup grace window. Import a renewed license, or open a temporary 7-day boot window with `hortval license force-grace --confirm` (capped at the license expiry date + 3 weeks; see [License enforcement / Force-grace](../administration/license-enforcement.md#force-grace-one-shot-escape-hatch)). **`license has been revoked by the server`** The server explicitly revoked the license. Contact support via the form on the portal. Force-grace is intentionally not available for revoked licenses. **`installation already registered under a different license`** The installation key is already bound to a different license on the server. Go to [hortval.com/account](https://hortval.com/account) to migrate the installation before running `hortval license register` again. ## Next step Once the license is active (or the cold-start window is open), start `hortval serve` and follow the [First certificate](./first-certificate.md) guide to verify the end-to-end flow with an ACME client. --- # getting-started/minimal-configuration.md --- sidebar_position: 4 title: Minimal configuration --- # Minimal Configuration Hortval is configured with a single YAML file. This page shows the smallest valid configuration to get started. :::tip Quick start If you'd rather have a working configuration generated for you, the [Quick start with the wizard](wizard.md) walks you through the same fields interactively and writes the YAML file for you. ::: ## Config File Location Pass the config file explicitly: ```bash hortval.exe -f C:\hortval\config.yml ``` Without `-f`, Hortval searches for `config.yml` / `config.yaml` in: 1. Executable directory 2. Windows: `%PROGRAMDATA%\hortval`, then `%APPDATA%\hortval` Linux: `/etc/hortval`, then `$XDG_CONFIG_HOME/hortval` 3. The same two directories named `certeasy` — the pre-rename location, still read, with a warning at startup. It will be removed in v2. Machine-wide comes before per-user on purpose: the per-user directory is the one an unprivileged account can write to. :::warning The current directory is no longer searched Up to v0.9.4 the working directory came first. A `config.yml` sitting in any directory someone could write to therefore took precedence over the one in `/etc` — and the configuration file selects the database, the working directory, the audit log destination and the outbound proxy. Pass `-f` if you were relying on it, or move the file to one of the locations above. ::: :::warning Two configuration files is an error, not a priority list If more than one of those files exists, Hortval refuses to start and names them all. No directory wins over another: pass `-f` to say which one to use, or remove the others. A file that silently shadows another is how a deployment ends up running a configuration nobody meant to apply. ::: ## Minimal Example This configuration relies on safe defaults wherever possible: ```yaml server: url: - "https://acme.corp.internal" listen: ":8443" tls-certificate-manager: bundles: - name: public mode: pki authority: ca1 dns-validation-profiles: - name: internal mode: local zones: - suffixes: - "corp.internal" system: true authorities: - name: ca1 type: adcs configuration: ca-name: "PKI\\LAB-RootCA" certificate-template: "ACME-Template-Server" issuance-policies: - name: corp-server dns: allow: - ".corp.internal/3" ``` ## What this configuration actually does In plain English: > Hortval listens on port 8443 and exposes itself at `https://acme.corp.internal`. > It contacts `LAB-RootCA` (your ADCS) to obtain a certificate for that hostname using the `ACME-Template-Server` template, and renews it automatically before expiry. > It accepts ACME certificate requests for any name under `corp.internal` (up to 3 labels), validates challenges using the system DNS resolver, and forwards CSR signing to the same `LAB-RootCA`. The authority `ca1` plays **two roles** here: it secures Hortval's own HTTPS endpoint **and** signs the certificates your ACME clients request. Both use the same ADCS CA and the same template. `ca-name` (`PKI\\LAB-RootCA`) is the name of your ADCS certification authority — the backslash-separated form is `\`. You can retrieve the exact value with `certutil -CA` on the ADCS host. `certificate-template` (`ACME-Template-Server`) is the name of the certificate template configured in ADCS for ACME enrollment. See [ADCS Configuration](../configuration/adcs) for how to set up the template and permissions. ## Workers Hortval processes certificate orders (validation, CSR submission, renewals) through an internal job queue. By default, **16 workers** consume that queue in the background. You don't need to configure this for a standard deployment — the default handles the load of most environments. Workers are only worth tuning if you have a very high volume of concurrent requests. ## Implicit policy binding This configuration has exactly one policy (`corp-server`) and one authority (`ca1`). Hortval connects them automatically — no `policy-bindings` section is needed. :::tip Think of it like a default route With a single destination, you don't need a routing table. As soon as you add a second authority (e.g. a pre-production CA), Hortval can no longer guess which policy routes where — you'll need to declare `policy-bindings` explicitly at that point. ::: :::info How PKI-mode TLS works On first startup, Hortval submits a CSR to your ADCS for a certificate covering `acme.corp.internal` (taken from `server.url`). The certificate is cached locally and renewed automatically before expiry. No manual certificate provisioning required. The issuance policy must cover the server hostname — `.corp.internal/3` handles `acme.corp.internal`. ::: ## What Each Section Does | Section | Purpose | |---|---| | `server` | ACME endpoint URL and listen address | | `tls-certificate-manager` | TLS certificate for the ACME HTTPS endpoint itself | | `dns-validation-profiles` | How Hortval resolves and validates DNS challenges | | `authorities` | Your ADCS backend | | `issuance-policies` | Which DNS names are allowed, key requirements | ## Startup checklist Before starting: - [ ] `server.url` is set to the hostname ACME clients will use - [ ] `ca-name` matches your ADCS CA exactly (check with `certutil -CA`) - [ ] `certificate-template` exists in ADCS and is configured for ACME enrollment - [ ] The service account has enroll permission on the template - [ ] Work directory is writable ## Next step The configuration file is now in place, but `hortval serve` will refuse to start without an active license — or an explicit **cold-start window** opened for evaluation. The [License](./license.md) page covers both paths: - Register or import the license you received from the portal, or - Open a 1-week cold-start window with `hortval cold-start init --plan=` to evaluate. Then proceed to the [First certificate](./first-certificate.md) guide. --- # getting-started/wizard.md --- sidebar_position: 2 title: Quick start with the wizard --- # Quick start with the wizard `hortval init` is the recommended way to get a working configuration in a few minutes. It walks you through a handful of questions — what to listen on, which database, which authority, which DNS zones you'll be issuing certificates for, how the server's own TLS cert should be obtained — and writes a valid `config.yml` you can `serve` immediately. It's not a black box: every prompt shows you a sensible default in brackets and explains what the field does. With an ADCS authority it runs a quick, read-only check against your CA (is it reachable, is the template published, what key does it require); in `--script` mode it stays fully offline. ## Run it After [installing the binary](installation.md), from the work directory: ```bash hortval init ``` That's it. By default it writes to `./config.yml`. Use `-o ` to put it elsewhere, or `--force` to overwrite an existing file. ## What it asks The flow is roughly: | Section | What you decide | |---|---| | **Network** | Listen address, public URL(s) as seen by your ACME clients | | **Database** | sqlite (default, Free and up) / postgres / sqlserver (Pro and Enterprise only). Connection details are turned into the right DSN. On PostgreSQL it also asks for the **schema** — leave it empty for the server default. On SQL Server it asks **how to authenticate**: as the Windows service account (integrated, no secret in the file) or with SQL credentials. It then offers to **test the connection**, and reports who the server saw you as. | | **Workdir** | Where the runtime files live | | **Authority** | `adcs` (Microsoft ADCS — the default) or `fake` (built-in lab PKI — generates its own root). For ADCS it asks for the CA name and template, **lists the CA's published templates so you pick the exact one** (no typos), and reads the template's key requirement to set the server certificate key for you. | | **DNS zones** | One zone at a time: zone name, maximum subdomain depth (with worked examples), wildcard policy. Add as many zones as you need. | | **clientAuth EKU** | Opt-in relaxation needed only if you plan to use `acme.sh` (which emits CSRs with an extra `clientAuth` EKU). Off by default. | | **Server's own TLS** | Issue from the authority above (`pki`), Let's Encrypt automatically, or supply your own files. With an ADCS authority the key type (RSA size / ECDSA curve) is set to match what the template requires. | | **Plan sizing** | Three quick questions (how many authorities, how many client servers, which DB). **Every answer names the smallest plan that allows it**, so the suggestion is never a surprise — and the wizard offers to open the cold-start window on the spot. | :::caution The `${...}` password placeholder is not expanded Leaving the password blank writes `${POSTGRES_PASSWORD}` or `${SQLSERVER_PASSWORD}` into the DSN. **Hortval does not read environment variables**: the placeholder is a marker for you, or for a tool like `envsubst`. Replace it with the value, or expand the file before starting: ```bash envsubst < config.yml > config.final.yml ``` `hortval validate` refuses a configuration whose DSN still carries one, so an unfinished file fails with a message that says so — rather than reaching the database and coming back as an authentication error. ::: ## What it generates A YAML file equivalent to `config-minimal.yml` plus the choices you made: - `server.listen` + `server.url` - `database.driver` (+ `path` or `dsn` depending on the driver) - `workdir` - `tls-certificate-manager.bundles[0]` (auto-filled hosts list from the public URL) - `dns-validation-profiles[0]` with each zone you declared - `authorities[0]` (fully configured, `fake` or `adcs`) - `issuance-policies[0]` with the depth/wildcard rules you chose, plus an explicit `=` allow so the server can always issue its own cert - `policy-bindings` written explicitly so the relationship is obvious ## What's next After the configuration step the wizard asks how you want to start the server: 1. **Open a cold-start window** — for evaluation / first run. It calls [`cold-start init`](license.md#option-3--cold-start-without-a-license-yet) with the suggested plan. 2. **Install a `.lic` file** — if you already have one. Equivalent to [`license install`](license.md#option-2--manual-file-import). 3. **Register a CRT-... key online** — equivalent to [`license register`](license.md#option-1--online-registration). You'll be asked for the deployment environment (prod, dev, staging, uat). 4. **Skip** — prints the commands you can run later. Whichever branch you pick, the wizard prints the final commands you need (typically `hortval serve`) and exits. ## Replay a session ```bash hortval init --save-script /tmp/answers.txt ``` writes every answer you typed to a small text file. To regenerate the same configuration on another machine — or to keep a reproducible setup recipe in your git repo — feed it back to the wizard via stdin: ```bash hortval init --script -o config.yml < /tmp/answers.txt ``` It's the same answers, same defaults, same output — no interactive prompts. Useful when you want a teammate to apply the exact same setup, or to keep a record of what was chosen on a server you no longer own. ## When to use the wizard vs. write YAML by hand The wizard covers the common cases: one authority, a few DNS zones, a straightforward TLS bundle, sqlite or a basic postgres/SQL Server. If your deployment is more exotic — multiple authorities with bindings, several DNS validation profiles, fine-tuned rate limits, a custom audit log location — start from the wizard's output and edit by hand from there. The [Minimal configuration](minimal-configuration.md) and reference pages cover every field. --- # intro/how-it-works.md --- sidebar_position: 2 title: How It Works --- # How It Works ## Architecture Overview ``` ACME Client (certbot, acme.sh, Caddy…) │ │ HTTPS RFC 8555 ▼ ┌─────────────────────────┐ │ Hortval │ │ │ │ ┌─────────────────┐ │ │ │ ACME Server │ │ │ │ (HTTP handlers)│ │ │ └────────┬────────┘ │ │ │ │ │ ┌────────▼────────┐ │ │ │ Challenge │ │ │ │ Validator │◄───┼─── DNS / HTTP / TLS-ALPN │ └────────┬────────┘ │ │ │ │ │ ┌────────▼────────┐ │ │ │ Issuance │ │ │ │ Policy Engine │ │ │ └────────┬────────┘ │ └───────────┼─────────────┘ │ enroll (in-process) ▼ ┌───────────────┐ │ Your ADCS │ │ (unchanged) │ └───────────────┘ ``` ## Step-by-Step Flow ### 1. Account Registration The ACME client creates an account on Hortval by submitting a JWK public key. Hortval stores the account and issues a unique account URL. ### 2. Order Creation The client requests a certificate by submitting a list of DNS identifiers (e.g. `app.corp.internal`, `*.corp.internal`). Hortval creates an order with one authorization per identifier. ### 3. Challenge Validation For each identifier, the client responds to a DNS-01, HTTP-01, or TLS-ALPN-01 challenge. Hortval validates the challenge asynchronously using its configured DNS validation profile. The validation profile controls: - which DNS resolver to use - which DNS zones are in scope - which resolved IP ranges are allowed ### 4. Issuance Policy Selection Once all challenges pass, the client submits a CSR to finalize the order. Hortval selects the appropriate **issuance policy** based on the requested identifiers. The issuance policy defines: - which DNS names are allowed - what key types and sizes are accepted - which ADCS authority handles the request ### 5. Certificate Issuance Hortval submits the validated CSR to the configured ADCS authority through its native in-process connector (or `certreq.exe` if you selected the `adcs-cli` connector). The authority issues the certificate according to the configured template. This step is asynchronous — Hortval polls ADCS until the certificate is ready. ### 6. Certificate Delivery The signed certificate (PEM chain, without private key) is stored and made available at the certificate URL. The client downloads it with a standard `GET` request. ## Async Job Engine Challenge validation and certificate issuance both run as **async jobs**. This decouples the ACME HTTP layer from the potentially slow operations of DNS validation and ADCS polling. Jobs are persisted in the database. If Hortval restarts mid-operation, jobs resume where they left off. ## Security Model Hortval enforces a strict security model at issuance time: - **Certificate identity limited to DNS**: SAN contains only the validated DNS names — no IP, UPN, or email - **No identity fields**: `O`, `OU`, `DC`, `L`, `ST`, `C` are forbidden in Subject - **Restricted EKU**: only Server Authentication (`1.3.6.1.5.5.7.3.1`) is allowed - **No UPN/email SAN**: prevents ADCS ESC attacks See [Security Model](../security/certificate-model.md) for full details. --- # intro/plans.md --- sidebar_position: 3 title: Plans & Pricing --- # Plans & Pricing ## Plans ### Free Ideal for small environments and proof-of-concept deployments. - **1 production installation** - **~25 managed servers** (distinct ACME accounts with at least one active certificate) - **1 ADCS production authority** - HTTP-01, DNS-01, TLS-ALPN-01 challenge validation - SQLite database ### Starter — €299 / year *(excl. VAT)* For small production environments. - **1 production installation** - **~250 managed servers** (distinct ACME accounts with at least one active certificate) - **2 ADCS production authorities** - HTTP-01, DNS-01, TLS-ALPN-01 challenge validation - SQLite database ### Pro — €499 / year *(excl. VAT)* For production environments and larger organizations. - **1 production installation** (cold Active/Passive supported) - **Unlimited managed servers** - **3 ADCS production authorities** - PostgreSQL database - SQL Server support - Dashboard *(coming in v4.0)* - Monitoring & alerting *(coming in v4.0)* ### Enterprise — €999 / year *(excl. VAT)* For organizations with advanced requirements. - Everything in Pro, plus: - **Up to 5 ADCS production authorities** - Beyond 5 CAs — [contact us](https://hortval.com/contact) - Split deployment: ADCS connector on Tier 0 + ACME responder on separate server *(coming in v2.0)* - Warm Active/Passive and Active/Active high availability (multi-node, requires PostgreSQL or SQL Server) *(coming in v2.0)* - Distributed validators (segmented networks) *(coming in v3.0)* - TLS service discovery *(coming in v4.0)* - Optional SLA :::tip High availability Today, Hortval supports **cold Active/Passive** with manual switchover (Pro and above): install it on two hosts sharing a PostgreSQL or SQL Server database, keep the standby fully stopped, and fail over by stopping the active node and starting the standby. Running two instances concurrently against the same database is **not supported** (see [Deployment topology](../administration/deployment-topology.md)). Warm Active/Passive and Active/Active high availability are planned for **v2.0 (Enterprise)**. ::: :::note License required A license file (`.lic`) is required to run Hortval, including on the Free plan. Registration takes 30 seconds and delivers the file by email. **Managed server quota** is counted as the number of distinct ACME accounts with at least one active (non-expired, non-revoked) certificate. Retries and re-issuances from the same ACME account do not count. An account with no active certificate (failed setup, tests) does not consume quota. Plan quotas (managed server count, number of authorities, allowed database driver) are enforced by the binary at startup and on every new order. Renewals continue to work even when the configuration exceeds the plan, so existing clients are never interrupted by a downgrade. See the [License enforcement page](../administration/license-enforcement.md) for the full behaviour. ::: ## Evaluation period All paid plans include a **6-month free trial** — sign up, no card required, no automatic charge. At the end, you choose to subscribe for a year or simply stop. If you subscribe, a new license file is sent to your email. Replace the existing `.lic` file on your server: no reinstallation, no configuration change. Your license is extended by one year from the trial expiry date, not from the payment date. On connected installations, auto-renewal can be configured so the binary fetches and replaces the file itself. On air-gapped servers, the manual file replacement is the only step required. [Start your free trial](https://hortval.com/trial) on the official site. :::note All prices exclude VAT. One activation slot = one ADCS CA fingerprint. Prices are locked — no unexpected increases. ::: --- # intro/roadmap.md --- sidebar_position: 4 title: Public roadmap --- # Public roadmap This page lists the major features Hortval ships progressively across versions, what drives each one, and which plan unlocks it. Subscribers on annual plans **lock the price** when they sign up — new features unlock on the same subscription as they ship. The roadmap is a planning indication, not a contractual commitment. Versions and feature ordering may change based on customer feedback. Legend: ✅ shipped · 🎯 next release in flight. ## Features by version | Feature | Version | Plan(s) | Driver | |---|---|---|---| | ACME core (RFC 8555: account / order / authz / challenge / finalize / revoke) | 0.9 ✅ | All | Standard interop with any ACME client | | ARI read-only (RFC 9773 `renewalInfo` endpoint) | 0.9 ✅ | All | Lets clients pick their own renewal window | | HTTP-01 / DNS-01 / TLS-ALPN-01 challenges | 0.9 ✅ | All | Validation flexibility on every network topology | | ADCS bridge via `certreq.exe` + built-in fake PKI for testing | 0.9 ✅ | All | Core promise: bridge ACME to your existing ADCS | | SQLite (default), PostgreSQL and SQL Server backends | 0.9 ✅ | All / PostgreSQL and SQL Server on Pro+ | Operators pick the persistence they already operate | | Tamper-evident audit log (JSONL + HMAC chain + `audit verify`) | 0.9 ✅ | All | Compliance and forensic without DB lock contention | | SQLite backup CLI (`backup create` / `backup verify`) | 0.9 ✅ | All | Disaster recovery without a 3rd-party tool | | License enforcement (strict boot + acknowledgement) | 0.9 ✅ | All | Predictable cost ceiling, no surprise billing | | Graceful HTTP shutdown | 0.9 ✅ | All | Zero in-flight cert lost on `systemctl restart` | | RFC 8555 `Location` headers audit complete | 0.9 ✅ | All | Conformance with strict-RFC ACME clients (NativeClient, Caddy) | | Native ADCS connector (in-process enrollment by default — no `certreq.exe` child process; `certreq.exe` stays available as the `adcs-cli` fallback) | 0.9.2 ✅ | All | Removes the LOLBin process chain that strict EDRs flag (Defender for Endpoint, CrowdStrike, SentinelOne) — eligible for stricter deployment perimeters | | Configuration validation (`hortval validate`, `nginx -t`-style) + fail-fast boot gate | 0.9.2 ✅ | All | Catch a bad configuration before startup, not halfway through | | Real ADCS revocation (CRL / OCSP propagation) | 0.9.3 ✅ | All | A revoked certificate is actually revoked end-to-end | | Configurable server-certificate key (RSA / ECDSA — e.g. RSA 4096 for RSA-only ADCS templates) | 0.9.3 ✅ | All | Start against CA templates that mandate a specific key type or size | | ADCS setup preflight (`hortval adcs check` + guided `init`: template picker, key-requirement detection, clear denial reasons) | 0.9.3 ✅ | All | Diagnose ADCS onboarding before go-live — fewer support tickets at setup | | Cleanup / retention of expired ACME records | 1.0 🎯 | All | Long-term operations: the database stops growing forever | | Health / metrics endpoints (`/healthz`, `/readyz`, Prometheus `/metrics`) | 1.0 🎯 | All | Drop-in integration with existing supervision (Zabbix, Centreon, Prometheus, Grafana) | | PKI health checks + load-balanced CAs (Ping at boot + runtime) | 1.0 🎯 | All | Mis-configured CAs fail loudly at boot; `round_robin` policy actually skips unhealthy CAs | | ADCS lab documentation (template setup, EKU, SAN, permissions) | 1.0 🎯 | All | Customers can deploy without contacting support | | ARI `replaces` semantics (RFC 9773 §5: link, persist, collapse window) | 1.1 | All | Full benefit of ARI in multi-instance fleets | | Split deployment (Tier 0 connector + ACME responder on separate host) | 2.0 | Enterprise | Keep the ADCS-touching component on Tier 0, expose ACME elsewhere | | Active/Active high availability (multi-node) | 2.0 | Enterprise | Uptime without a manual failover step | | External Account Binding (EAB, RFC 8555 §7.3.4) | 2.0 | All | Multi-tenant DevOps deployments (per-team credentials) | | Distributed validators | 3.0 | Enterprise | Reach internal services that the central node cannot validate (split-DNS, restricted egress) | | Web dashboard | 4.0 | Pro / Enterprise | Quick operator view without parsing the audit log | | Monitoring & alerting templates (Grafana, Centreon) | 4.0 | Pro / Enterprise | Alert quick-start without writing your own queries | | TLS service discovery (probe + deployment status) | 4.0 | Enterprise | End-to-end loop: from "issued" to "actually deployed and serving" | ## Compliance and RFC gaps The RFC and integration gaps documented in [Standards & RFC support](../reference/standards-compliance.md) are tracked in the table above. ADCS revocation propagation closed in **0.9.3 ✅**; the remaining "1.0 🎯" entries close the operational gaps still visible to a standard ACME client today. External Account Binding (EAB) is planned for 2.0. ## Pricing and feature gating Each feature above is tagged with the plan that includes it and the version it ships in. See the [pricing page](https://hortval.com/#pricing) for the current line-up and the [plans documentation](./plans.md) for what each tier includes. Subscribe today on an annual plan to **lock the price** and follow the feature ramp without any annual increase. --- # intro/what-is-hortval.md --- sidebar_position: 1 title: What is Hortval? (formerly Certeasy) sidebar_label: What is Hortval? description: Hortval — formerly Certeasy — is an on-premise ACME server that automates TLS certificate issuance from your internal Microsoft ADCS PKI, without exposing it and without leaving your network. --- # What is Hortval (Certeasy)? :::info Certeasy is now Hortval Same product, same team, new name. If you are running **0.9.4**, its binary and commands are still named `certeasy`: pick that version in the version menu, and see [Upgrading](../upgrading.md) for what changes. ::: Hortval is an **on-premise ACME server** that bridges standard ACME clients (certbot, acme.sh, Caddy, Traefik…) with your internal **Active Directory Certificate Services (ADCS)** PKI. It lets you automate TLS certificate issuance inside your organization — without relying on any external cloud service, without exposing your PKI, and without changing your existing infrastructure. ## The Problem Active Directory takes care of Windows machines: certificates are deployed automatically through Group Policy, no one has to think about it. Linux servers, reverse proxies, load balancers, and containers are a different story. ADCS was never designed for them, so teams fill the gap however they can: - Certificates managed manually, renewed by hand → forgotten renewals, outages - Custom scripts around `certreq.exe` → fragile, hard to audit, breaks on updates - External CAs for internal services → certificates issued outside your network, outside your policies ## The Solution Hortval sits between your ACME clients and your ADCS. It: 1. Exposes a standard ACME endpoint that any ACME client can talk to 2. Validates the ACME challenge — `dns-01`, `http-01` or `tls-alpn-01` — to confirm ownership of the requested domain 3. Submits the CSR to your ADCS authority in-process (no child process), with a `certreq.exe` connector available as a fallback 4. Returns the signed certificate to the ACME client Your ADCS never changes. Your ACME clients don't know they're talking to an internal CA. Everything stays inside your network. ## Key Properties | Property | Detail | |---|---| | **100% on-premise** | No data leaves your network | | **Standard protocol** | RFC 8555 ACME + RFC 9773 ARI (read-only) — works with any ACME client. See [Standards & RFC support](../reference/standards-compliance.md) for the detailed conformance matrix. | | **ADCS-native** | Connects to ADCS in-process — no `certreq.exe` child process, no ADCS changes required (a `certreq.exe` connector remains available as a fallback) | | **Secure by default** | Conservative defaults: RSA 3072-bit minimum, strict algorithm allow-list | | **Hardened against ADCS attacks** | Certificate identity limited to validated DNS names — prevents ESC1–ESC13 by design | | **Isolated networks** | Supports segmented environments (v2.0) | | **Auditable** | Full audit log of all certificate operations | ## What Hortval Is Not - Not a CA — it delegates issuance to your existing ADCS - Not a cloud service — it runs entirely inside your infrastructure - Not a replacement for your PKI — it automates access to it --- # reference/faq.md --- sidebar_position: 1 title: FAQ --- # Frequently asked questions ## My PKI / ADCS template only allows RSA — how do I configure Hortval? {#rsa-only-templates} RSA-only templates (for example a Microsoft ADCS template with a minimum key size of 4096 and an RSA provider) reject ECDSA keys. Two different keys are involved, and both must be RSA. **1. Hortval's own server certificate.** Hortval generates this key and defaults to ECDSA P-256. Pin it to RSA on the certificate-manager bundle: ```yaml tls-certificate-manager: bundles: - name: server mode: pki authority: ca1 key: type: rsa size: 4096 ``` See [TLS certificate manager → Key type](../configuration/tls.md#key-type). **2. Certificates issued to ACME clients.** Here the *client* generates the key, so the key type is chosen on the client, not in Hortval. Most clients default to ECDSA (or RSA 2048), which an RSA-4096 template rejects — set it explicitly: | Client | Option | |---|---| | lego | `--key-type rsa4096` | | certbot | `--key-type rsa --rsa-key-size 4096` | | acme.sh | `--keylength 4096` | **3. (Recommended) Reject a wrong key early.** Constrain the issuance policy to match the template, so a non-conforming client request is refused by Hortval with a clear message rather than forwarded and denied opaquely by the CA: ```yaml issuance-policies: - name: adcs-rsa signature: allowed-algorithms: - "RSA-SHA256" - "RSA-SHA384" - "RSA-SHA512" min-rsa-bits: 4096 ``` :::note Lists must be written as block sequences (one `-` item per line). Hortval's configuration parser does not accept YAML flow sequences (`["a", "b"]`) and will refuse to start with `expected sequence (use '-' items)`. ::: :::tip Use a certificate template **dedicated** to Hortval — it lets the key requirement, the SAN policy and the revocation permission be scoped to Hortval without affecting your other templates. Run [`hortval adcs check`](../configuration/adcs.md#preflight-your-setup-hortval-adcs-check) to confirm the template is published and see its key requirement before you start the server. ::: ## Windows refuses to run the binary on my server, and there is no way to continue {#smartscreen-no-bypass} Symptom: you download `hortval-vX.Y.Z-windows-amd64.exe`, double-click it, and get *"Windows protected your PC"* with a single **Don't run** button. Clicking **More info** shows the publisher but offers no way through. Nothing is wrong with the download. Two things are happening at once: **The file carries the Mark of the Web** — an NTFS stream your browser attached because it came from the Internet. That is what raises the prompt; a copy from an internal share or a `curl.exe` download never shows it. **Your server forbids the override.** The Microsoft Windows Server security baseline sets *Configure Windows Defender SmartScreen* to **"Warn and prevent bypass"**, which removes the *Run anyway* button by design. Check with: ```powershell Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System' | Select-Object EnableSmartScreen, ShellSmartScreenLevel ``` `ShellSmartScreenLevel = Block` is that policy. **The fix is not to weaken the policy.** Verify the signature, then clear the mark — in that order, because unblocking is you asserting where the file came from, and that assertion is only worth something once you have checked it: ```powershell Get-AuthenticodeSignature .\hortval-vX.Y.Z-windows-amd64.exe | Format-List Status,SignerCertificate # Status must be Valid, signer SAFE PIC TECHNOLOGIES Unblock-File .\hortval-vX.Y.Z-windows-amd64.exe ``` The binary then starts normally. Full detail, including what SmartScreen actually checks and what signing does and does not change: [Verifying release binaries](../security/verifying-binaries.md). :::tip Deploying to many servers Fetch the release with `curl.exe`, an internal mirror or your software distribution tool: none of them attaches the mark, so none of this applies. Verify the signature once, on the copy you are about to distribute. ::: --- # reference/full-example.md --- sidebar_position: 1 title: Full Configuration Example --- # Full Configuration Example A complete configuration file with every available option. All optional fields are included with their default values or representative examples. Comments indicate which fields are required, optional, or mode-specific. ```yaml # Base directory for runtime files (SQLite, TLS cache, logs). # Default: %ProgramData%\hortval (Windows) | /var/lib/hortval (Linux) workdir: 'C:\ProgramData\hortval' # ── Database ────────────────────────────────────────────────────────────────── # Omit this section entirely to use SQLite with all defaults. database: driver: sqlite # sqlite | postgres | sqlserver path: "" # SQLite only — defaults to %WORKDIR%/db.sqlite dsn: "" # PostgreSQL and SQL Server connection string ping-timeout-sec: 10 max-idle-conn: 2 # Default: 2 (SQLite) | 5 (postgres/sqlserver) max-conn: 10 conn-max-lifetime: 2m # Default: 2m (postgres/sqlserver), unset for SQLite. # Sits below typical firewall/NAT idle timeouts so the # pool recycles before the network drops a connection. conn-max-idle-time: 1m # Default: 1m (postgres/sqlserver), unset for SQLite noddl: false # true when the account holds no schema rights: # Hortval issues no DDL, checks the schema at startup, # and `hortval migrate` writes the SQL for your DBA # ── Server ──────────────────────────────────────────────────────────────────── server: url: - "https://acme.corp.internal" # Public URL(s) for ACME clients — required listen: "0.0.0.0:8443" read-header-timeout: 5s read-timeout: 10s write-timeout: 30s idle-timeout: 60s max-body-bytes: 1048576 # 1 MB shutdown-timeout: 30s remote-ip-header: "X-Forwarded-For" # Only used when trusted-proxies is set trusted-proxies: - "10.0.0.0/8" # ── Logs ────────────────────────────────────────────────────────────────────── logs: level: info # debug | info | warn | error format: json # json | text output: file # stderr | stdout | file # With rotation enabled, `file` is a NAMING BASE, not a file: segments are # written beside it as ...log and are never # renamed. Point log collectors at the directory glob, not at this path. file: "C:\\ProgramData\\hortval\\hortval.log" rotate: max-size-mb: 100 max-backups: 10 # closed segments kept; 0 = none, -1 = never delete services: # Per-service log level overrides DB-Driver: warn adcs: info acme-server: info Async-Acme-Pki-Handler: info Async-Acme-Challenges: info JWKS: warn worker: info http-server: info tags: # Free-form labels added to every log entry (Grafana/Loki) instance: cert-srv-01 region: eu-west # ── TLS Certificate Manager ─────────────────────────────────────────────────── tls-certificate-manager: acquire-timeout: 2m # pki mode only renew-before: 720h # pki mode only — 30 days pki-poll-interval: 2s # pki mode only file-watch-interval: 5s # files mode only local-pki-cache-dir: "%WORKDIR%\\server-certificate-cache" # pki mode only # letsencrypt mode (beta) — public CA for an internet-facing host: letsencrypt: enabled: false # set true when a bundle uses mode: letsencrypt email: "pki@example.com" # ACME account / expiry notices http-addr: ":80" # HTTP-01 challenge listener cache-dir: "%WORKDIR%\\autocert" # issued-cert + account-key cache bundles: - name: public hosts: - "acme.corp.internal" mode: pki # files | pki | letsencrypt (beta) authority: ca1 # pki mode — authority name # files mode fields (use instead of authority): # local-cert-file: "C:\\hortval\\tls\\fullchain.pem" # local-key-file: "C:\\hortval\\tls\\privkey.pem" # ── DNS Validation Profiles ─────────────────────────────────────────────────── dns-validation-profiles: - name: internal-default mode: local # local only (remote: not yet implemented) timeout: "" # overall validation timeout zones: - suffixes: - "corp.internal" system: true # use system DNS resolver dns-server: "" # explicit resolver (overrides system) authoritative: false dnssec: false protocol: udp # udp | tcp resolved-ip-policy: allow-cidrs: - "10.0.0.0/8" deny-cidrs: - "127.0.0.0/8" - "169.254.0.0/16" - "::1/128" - "fe80::/10" # ── Authorities ─────────────────────────────────────────────────────────────── authorities: - name: ca1 type: adcs # adcs/adcs-native (in-process, default) | adcs-cli (certreq.exe) | fake disable-ca-revocation: false # default — propagate revocations to the CA (needs the Certificate Manager role) configuration: ca-name: "PKI\\LAB-RootCA" # as shown by certutil -CA certificate-template: "ACME-Template-Server" default-timeout: 4m # keep below workers.max-job-duration (default 5m) # Taken from the Windows system directory by default (since 0.9.4). # Set these only to run a copy kept elsewhere, e.g. on a path carved out # of an EDR policy. adcs-cli connector only. # certreq-path: "C:\\Tools\\certreq.exe" # certutil-path: "C:\\Tools\\certutil.exe" # used for revocation # Fake PKI for local testing — do not use in production # - name: test-ca # type: fake # configuration: # common-name: "Hortval Test CA" # password: "testpassword" # key-size: 4096 # validity: 3650 # CA certificate lifetime, in days # certificate-validity: 2160h # issued-certificate lifetime (default 90 days) # ── Issuance Policies ───────────────────────────────────────────────────────── issuance-policies: - name: corp-server dns-validation-profile: internal-default # required if multiple profiles exist dns: allow: - ".corp.internal/3" # non-wildcard names, max 3 labels - "*.corp.internal" # wildcard at zone root only deny: - "=forbidden.corp.internal" # exact match deny # Both lists REPLACE the secure defaults, they do not extend them: write # every value you want. Each EC curve is pinned to one algorithm, so a # curve without its algorithm (or the reverse) is unusable — Hortval says # so at startup and in `hortval validate`. signature: allowed-algorithms: - "RSA-SHA256" - "RSA-SHA384" - "RSA-SHA512" - "ECDSA-SHA256" # pairs with P-256 - "ECDSA-SHA384" # pairs with P-384 - "ECDSA-SHA512" # pairs with P-521 - "ED25519" min-rsa-bits: 3072 allowed-ec-curves: - "P-256" - "P-384" - "P-521" # required by ECDSA-SHA512 above # CSR Extended Key Usage whitelist. Default: serverAuth only. # See SECURITY WARNING in configuration/issuance-policies.md before # adding non-server purposes — back-end ADCS templates configured as # "Supply in the request" will honor the CSR's EKU. # # clientAuth note: acme.sh's default OpenSSL template emits # EKU=serverAuth,clientAuth. The CA/B Forum baseline forbids this # combination on publicly-trusted certs from June 2026 onwards. Keep # the entry below ONLY if you must support unmodified acme.sh; lego # and certbot emit serverAuth only and don't need it. See # configuration/issuance-policies.md for the full discussion. # csr: # allowed-extra-eku: # - clientAuth # # - codeSigning # # - emailProtection # # raw OID also accepted, e.g. Microsoft EFS: # # - "1.3.6.1.4.1.311.10.3.4" # ── Policy Bindings ─────────────────────────────────────────────────────────── # Can be omitted when there is exactly one policy and one authority. policy-bindings: - policy: corp-server authorities: - ca1 strategy: first_available # first_available | round_robin # ── Workers ─────────────────────────────────────────────────────────────────── workers: worker-id: "worker" workers: 16 lease: 30s idle-min: 50ms idle-max: 200ms base-backoff: 1s max-backoff: 2m queue-size: 16 # defaults to value of workers # ── Rate Limiting ───────────────────────────────────────────────────────────── # Omit this section entirely to apply the defaults shown below. # Whitelist is intentionally empty — secure by default, no IP is auto-trusted. rate-limiting: whitelist: # OPTIONAL — entries bypass IP-based limits # Add only if you have a specific reason # (e.g. monitoring probes you control). # Example values (commented out by default): # - "127.0.0.1" # - "10.42.0.0/16" global: # Per-IP token bucket on every endpoint. enabled: true # Comfort ceiling — sized so a legitimate requests-per-minute: 1200 # client is never the one it stops. burst: 100 abuse: # Per-IP marking, not a request ceiling. enabled: true # A marked IP is refused on EVERYTHING, abuses-before-block: 10 # not only on further misbehaviour. recovery-per-minute: 20 # Gradual recovery, not a fixed ban. account-creation: # Per-IP cap on new-account enabled: true per-ip-per-hour: 10 # burst == hourly: account creation happens burst: 10 # in deployment waves, not at a steady rate order-creation: # Per-account caps on new-order enabled: true orders-per-account-per-hour: 20 order-burst: 5 san-budget-per-account-per-hour: 100 duplicate-certificate: # Anti-runaway: same FQDN set per account enabled: true # Set to false to disable entirely max-per-window: 5 window: 168h # 7 days failed-validation: # Anti-misconfig: bucket per (account, hostname) enabled: true # Counter lives in memory only max-per-window: 5 window: 1h pending-authorizations: # Anti-DoS: in-flight authzs per account enabled: true max: 30 # Calibrated for 1 machine = 1 account # ── Renewal Information (ARI, RFC 9773) ─────────────────────────────────────── # Always active — endpoint advertised in /directory as renewalInfo. # Omit this section to apply the defaults. renewal-info: lifetime-fraction: 2/3 # Window opens at notBefore + lifetime*2/3 # Decimal (0.75) or fraction (2/3) both accepted window-width: 48h # Spread renewals across this duration retry-after: 6h # Sent as Retry-After header on responses # ── Audit log (HMAC-chained JSONL) ──────────────────────────────────────────── # Enabled by default. Omit this section to apply the defaults shown below. # Verify the chain with: hortval audit verify -f config.yml audit: enabled: true path: "" # Empty → /audit.log rotate: # 0 → a single segment that grows indefinitely. Above 0, Hortval starts a # new dated segment at that size. External rotation (logrotate, Scheduled # Task) on this file is NOT supported: it breaks the tamper-evident chain. # There is no max-backups here — Hortval never deletes an audit segment. max-size-mb: 0 ``` --- # reference/standards-compliance.md --- sidebar_position: 2 title: Standards & RFC support --- # Standards & RFC support Hortval implements the IETF ACME family of standards. This page documents which parts of each RFC are supported today, which are partial, and which are planned. Use it when auditing Hortval against a compliance requirement or before integrating an ACME client that depends on a specific feature. ## RFC 8555 — ACME core protocol **Status**: Supported with known gaps documented below. | Feature | RFC § | Status | |---|---|---| | Account create / contact update / key change / deactivate | §7.3 | ✅ | | `newOrder` and order state machine | §7.4 | ✅ | | Authorization + challenge dispatch (HTTP-01, DNS-01, TLS-ALPN-01) | §7.5, §8 | ✅ | | `finalize` + CSR validation | §7.4 | ✅ | | `revoke-cert` signed with the account key (`kid`) or the certificate key (`jwk`) | §7.6 | ✅ (propagates to the ADCS CA — see notes) | | Wildcard issuance (`*.zone`, mixed `zone + *.zone` in one order) | §7.1.4, §8.4 | ✅ | | `Location` headers and response URLs canonicalization | §7.4 and following | ✅ | | External Account Binding (EAB) | §7.3.4 | 🔴 not supported in v0.9 / v1.0 — planned for v2.0 | ### Known limitations #### ADCS revocation propagation (shipped in 0.9.3) Since **0.9.3**, `POST /acme/revoke-cert` propagates to the backing ADCS CA — the certificate is revoked on the CA itself, not only in Hortval's database. Two things to know: - Revoking on the CA requires the **Certificate Manager** role on the ADCS service account — a higher privilege than enrollment. If the account only holds enrollment rights (or the deployment is air-gapped), turn propagation off per authority with `disable-ca-revocation: true`; revocation then stays server-side only, as before. - Propagation to the CA is immediate, but a client validating chain status still sees the certificate as valid until the CA **publishes its next CRL** (or its OCSP responder refreshes). That cadence is governed by your ADCS CRL publication schedule, not by Hortval. #### External Account Binding (EAB) — planned for v2.0 EAB lets you bind a new ACME account to an out-of-band identity (HMAC key shared via your provisioning system). Useful for multi-tenant DevOps deployments where each team is given its own credentials. Not implemented in v0.9 / v1.0 — single-tenant enterprise deployments do not need it. Tracked on the [roadmap](../intro/roadmap.md) for v2.0. ## RFC 9773 — ACME Renewal Information (ARI) **Status**: Partial — read-only ARI is supported, the `replaces` hint is silently accepted. | Feature | RFC § | Status | |---|---|---| | `renewalInfo` directory entry | §3 | ✅ | | `GET /acme/renewal-info/` with suggested window + `Retry-After` | §4 | ✅ | | `newOrder.replaces` validation + persistence + `renewalInfo` window collapse on the replaced cert | §5 | 🟡 field accepted silently — full semantics planned for v1.1 | ### Known limitations #### `replaces` semantics (planned for v1.1) ARI-aware clients (recent lego, certbot) can send `newOrder.replaces` without seeing a `400 malformed` — Hortval accepts the field. However the server does not yet: - Reject duplicate `replaces` with `409 alreadyReplaced`. - Persist the `old_cert → new_cert` link. - Collapse the replaced certificate's `renewalInfo` window to force renewal of other clients holding the old cert. In a single-instance deployment this is invisible. In multi-instance or HA deployments the full ARI benefit is reduced until the semantics ship. ## RFC 5280 — X.509 certificates **Status**: Supported. - Issued certificates carry the **Subject Alternative Name** extension only, with DNS names from the validated authorizations. - The **Subject Common Name** field is intentionally left empty. This follows the CA/Browser Forum baseline since 2019: modern TLS clients (Chrome, Firefox, Go ≥ 1.15, Java ≥ 11) validate against SAN, not CN. Legacy clients that still require a CN-based match may need adjustment. - **Extended Key Usage**: `serverAuth` only. `clientAuth`, `codeSigning`, `anyPurpose` and other EKUs are never set, even if requested by the client's CSR. Certificates issued by Hortval are TLS server certificates — never reusable for AD authentication, SMB signing, or other server-side roles. The strict EKU policy can be relaxed per-policy via `csr.allowed-extra-eku` if a specific client (e.g. acme.sh) declares additional EKUs in its CSR. - **Signature algorithms**: configurable allow-list per policy, default minimum RSA 3072-bit, ECDSA P-256 and above. ## Roadmap The remaining gaps on this page are tracked in the [public roadmap](../intro/roadmap.md) and target the next minor releases. Subscribe now (Pro, Enterprise) to lock the price while features ship progressively. --- # reference/test-coverage.md --- sidebar_position: 3 title: Test coverage --- # Test coverage Every Hortval release ships only after the full automated test suite passes against the targeted database backends. This page lists the suites that compose the gate, what each one verifies, and the headline counts at the time of writing. ## Headline numbers | Category | Tests | What it verifies | |---|---|---| | Unit (TU) | **865** | Pure logic: configuration parsing and validation, policy resolution, JWS signing and verification, anti-replay nonces, DNS scope matching, CSR validation, key handling, the asynchronous job engine, licensing decisions, rate-limit decision tables, audit-line encoding. No I/O, no database. | | Integration (IT) | **188** | Real database (SQLite, PostgreSQL, SQL Server), real audit file on disk, real PKI request store, full ACME handler stack wired against the storage layer. Each test runs against every supported database backend. | | End-to-end (E2E) | **167** | The full Hortval binary running as a subprocess. Two flavours: (1) CLI black-box — every subcommand (`serve`, `init`, `validate`, `migrate`, `license`, `cold-start`, `backup`, `audit`, `adcs check`), exit codes, error messages, **each run against every supported database backend**. (2) ACME protocol — real third-party clients (lego, certbot, acme.sh) plus a RFC-strict native client driving certificate issuance, renewal, revocation, account lifecycle, key rollover, and the full error/security path. | | **Total** | **1220** | | Numbers are refreshed at every release. The count above reflects the **v0.9.5** line — **+168 tests since v0.9.4**. These count test *functions*, not executions. Most run several times — once per database backend — so a full run reports a much larger figure: the v0.9.5 Windows run executed **5,680** tests across 39 phases, with **80** skipped and no failure. Every skip — including a skipped sub-case inside a larger test — is declared in a reference kept under version control, and every full run compares against it **in both directions**: a test that starts skipping without being declared, and a declared skip that no longer happens. Coverage cannot quietly erode between releases, and a test that comes back to life is noticed rather than assumed. ## What is covered, by area ### ACME protocol (RFC 8555 + RFC 9773) - Account: create, lookup-by-key, contact update, deactivate, key rollover (including the `409 Conflict` path when rolling to an already-used key). - Order: create, get, finalize, state machine transitions, expiry. - Authorization + challenges: HTTP-01, DNS-01, TLS-ALPN-01 — happy path and every documented failure mode. - Wildcards: pure `*.zone` and mixed `zone + *.zone` in a single order. - Renewal information (ARI): suggested window, `Retry-After`, revoked-cert collapse to immediate renewal. - Revocation: client-signed and account-key-signed paths, double-revoke rejection, and — against Microsoft ADCS — real propagation to the certificate authority (see below). - URL and header conformance: every endpoint where RFC 8555 requires a `Location` header is asserted on the wire. - Anti-replay nonces: single-use enforcement, validity under concurrent clients running at uneven speeds, and both window edges. - Account keys: RSA, ECDSA and Ed25519. One key yields one account, whatever the encoding of its JWK. ### ACME client interoperability E2E suite runs the full happy-path issuance against: - **lego** — HTTP-01, DNS-01, TLS-ALPN-01. - **certbot** — HTTP-01, DNS-01. - **acme.sh** — HTTP-01, DNS-01, TLS-ALPN-01. - A built-in, RFC-strict native client for the paths the third-party CLIs do not exercise: error-path, security probes, account lifecycle operations, and RFC URL/header conformance. One scenario runs the three CLIs simultaneously against a single server, each solving a different challenge type. Another issues and renews an ECDSA certificate under an ECDSA account key. ### Microsoft ADCS integration When an ADCS lab is available, Hortval is validated against a real Active Directory Certificate Services authority: - **Issuance** through both supported ADCS connectors, so an upgrade never changes behaviour silently. - **Revocation propagated to the CA**, then confirmed on the authority itself — a revoked certificate is verified as revoked at the source, not only in Hortval's own records. - **Onboarding checks** — the `adcs check` command and the setup wizard verify that the CA is reachable, that the certificate template is published, and read the template's key requirements, so common misconfigurations surface at setup time instead of as opaque startup failures. - **Clear diagnostics** — when the CA refuses a request, the underlying reason is surfaced with actionable guidance (for example, a key that does not meet the template's requirements), independent of the CA's display language. - **Server-certificate key selection** — Hortval's own certificate can be issued as RSA or ECDSA at the strength the CA template mandates, and this selection is verified end to end. - **Key-algorithm routing** — an ADCS template can pin the key algorithm, and ACME clients disagree on their default: some generate ECDSA, others RSA. Where a separate template is declared for each, a certificate is issued from the one matching the client's key. Verified against a real CA carrying two algorithm-pinned templates, with clients of both families in the same run. ### Database support The integration suite runs the same test set against every supported backend: - **SQLite** — always. - **PostgreSQL** — when configured in the CI environment. - **SQL Server** — when configured in the CI environment, including **integrated authentication**: Hortval connects as its own Windows service account, with no password in the configuration file. That path is exercised against a domain-joined SQL Server, and the suite refuses to run unless the server itself confirms the session was authenticated by Kerberos under a Windows account — a password left in the connection string cannot pass for it. **The CLI black-box suite runs against all three as well**: `migrate`, the schema gate, the cold-start plans and `license install` are each exercised on every supported engine. The one exception is `backup`, SQLite-only by design in v1. Concurrent-writer behaviour (serialisable retry) and dialect-specific edge cases (UUID handling, NULL semantics in unique indexes, cascade chain restrictions on SQL Server) are all covered. Test isolation is per-schema rather than per-database on PostgreSQL and SQL Server: each test gets its own schema, and on SQL Server its own login and user so unqualified identifiers resolve there rather than falling back to `dbo`. ### Schema and migrations The upgrade path is treated as a component in its own right, because its failures are silent and reach a database nobody can inspect afterwards. - **Every upgrade path between versions**, on the three backends: applied in order, out of order, partially, and interrupted mid-run. - **The startup contract**: additive changes applied automatically, a breaking change refused with the remedy named, a database newer than the binary refused, and the mixed case a failed upgrade followed by a rollback leaves. - **A migration already applied is verified against the binary that meets it**, so a schema that no longer matches the code is refused rather than used. - **Every table created by a migration is checked to be declared**, in both directions — an undeclared table and a declared table that no longer exists both fail the suite. - **The generated SQL** (`hortval migrate --sql`) is compared against what a real migration executes, statement by statement, and applied end-to-end to confirm the resulting database satisfies the binary. - **Schema resolution on PostgreSQL and SQL Server** is asserted against the engines themselves rather than assumed from their documentation: where an unqualified name resolves, where a table is created, and which schema wins when the same name exists twice. Hortval's behaviour on a shared database rests on those answers, so they are re-checked at every release. ### Asynchronous issuance & revocation Certificate signing and revocation against a CA run through an asynchronous job engine, and its reliability guarantees are tested: - Retries with escalating back-off when the CA is momentarily unavailable, up to a bounded window before a request is marked failed. - Idempotent processing — a job that is retried, or replayed after a crash, never issues or revokes twice. - The full issuance and revocation job lifecycle, including the audit record emitted at each terminal outcome. ### Server certificate management Hortval manages its own TLS certificate, and each source is exercised: - **Static files** — loaded from disk and hot-reloaded when they change. - **Internal PKI** — issued and automatically renewed from a configured authority, with the key algorithm and strength selectable to match the CA template (see [Microsoft ADCS integration](#microsoft-adcs-integration)). - **Let's Encrypt** — automatic ACME issuance. - Startup acquisition, renewal timing, and local caching are all covered. ### Resilience & recovery - **Node identity** — each server instance has a stable identity that anchors its audit trail; work in flight is recovered correctly across a process restart. - **Graceful shutdown** — in-flight requests and jobs drain within the configured window, and terminal database writes survive a shutdown signal. - **The built-in test authority is flagged** — selecting it produces an unconditional warning, surfaced by both configuration validation and startup. - **Certificate authority material is never overwritten** — for the built-in test authority, every startup path is covered: absent key, correct password, wrong password, corrupt key, empty files, missing certificate, certificate not matching the key, changed common name, unreadable certificate. Each refusal asserts both files are unchanged byte for byte, and an authority written by the previous version starts unchanged on the current one. ### Configuration - The configuration parser is covered for strict schema checking (unknown fields are rejected), quoting and escaping, and Windows path handling. - `hortval validate` runs exactly the same static validation as the server's fail-fast boot gate, with no side effects — an invalid configuration is caught before startup rather than halfway through it. - A partial section leaves the other keys at their documented defaults; an explicit value is honoured or rejected, never silently replaced. ### Rate limiting End-to-end suite (7 tests) under a tight profile, covering global denial, account-creation throttling, order-creation throttling, duplicate-certificate refusal, failed-validation back-off, and the pending-authorization cap. A further 22 tests cover the two per-address buckets: refusal of a misbehaving address, isolation of its neighbours, whitelist exemption, threshold weighting, and the outcomes that deliberately do not count as abuse (stale nonce, expired order, licence refusal). ### Audit log - Round-trip write + verify on every supported database backend. - The audit secret is redacted from database debug logs, checked on both log formats, and stored and read back unchanged on every supported backend. - HMAC chain anchoring on the installation key. - Recovery across process restart, including rotated files. Rotation writes dated segments that are never renamed. - Tampering detection (line removed, line modified, MAC altered, wrong installation key). - Every protocol event (account create / key change / deactivate, order create / finalize / invalid, authorization & challenge validate, certificate issue / revoke, rate-limit deny, license deny) is asserted to fire exactly once at the right point in the request lifecycle. ### License enforcement Licensing is enforced at startup and at runtime, including the recovery paths that keep a server running through a degraded or expired state. ### CLI Every subcommand and flag is exercised in the E2E suite — including `serve`, `init`, `validate`, `migrate`, `license`, `backup`, `audit verify`, and the ADCS preflight `adcs check`: argument validation, exit codes, error messages on missing file / bad format / incompatible flag combinations, help output for every command level. ### Backup & restore - SQLite snapshot round-trip with schema verification. - Integrity check (`quick` and `full`). - Refusal to overwrite an existing target. - Verify against a corrupted file / missing tables / wrong driver. ### Cross-platform The suite runs on Linux and macOS for every release, and **the whole suite runs on Windows** — not a subset. The v0.9.5 Windows run executed 39 phases across the three database backends with no failure. That matters because Windows is where the product is deployed, and several behaviours exist only there: where files land once the working directory is resolved, how anchored paths expand, and how the process drains when asked to stop. Each is covered on Windows itself, against all three database backends. On Windows, when an ADCS lab is available, the protocol suite additionally runs against a real Active Directory Certificate Services authority — covering both certificate issuance and revocation, and both ADCS connectors (see [Microsoft ADCS integration](#microsoft-adcs-integration)). ## How the categories are defined Classification is purely based on the file path of the test, so the numbers are reproducible without judgement calls: - **End-to-end (E2E)** — tests that run the Hortval binary as a subprocess and assert on the wire or the CLI output. - **Integration (IT)** — tests that hit a real database, write a real audit file to disk, or wire the full handler stack against a real storage backend. - **Unit (TU)** — every other test: pure-function logic with no I/O. A test that touches both a database and a real subprocess counts as E2E (the more demanding category wins). ## Where the numbers come from The full suite is launched from the repository root with a single command that records pass / fail / skip per module and writes per-module logs for diagnostics. The headline numbers above come from enumerating the whole test suite and classifying each test by an objective, path-based rule. --- # security/certificate-model.md --- sidebar_position: 1 title: Certificate Security Model --- # Certificate Security Model Hortval enforces a strict certificate identity model at issuance time. This behavior is **mandatory, non-configurable, and secure by default**. :::warning These rules are enforced on the request, not on what your CA returns Every rule on this page is checked against the CSR and the ACME order, before submission. The certificate your CA issues is **not** re-checked against them. Hortval prevents an ACME client from *asking* for a dangerous certificate. It does not prevent your CA from *issuing* one. If the ADCS template adds a SAN, sets a different Subject, or grants a broader EKU than was requested, Hortval returns that certificate to the client without detecting the difference. The mitigations below are therefore only as strong as the template they are paired with. See [ADCS hardening & shared responsibility](./hardening.md). ::: ## Core Principle > ACME proves **control over a DNS identifier** — nothing else. ACME does not prove organizational identity, user identity, Active Directory account ownership, or authorization to authenticate to AD. Any certificate content beyond validated DNS names cannot be justified by the ACME protocol. ## Subject Rules ### What is allowed - An **empty Subject**, or - `CN = one of the validated DNS names` ### What is forbidden All other Subject fields are rejected: | Field | Reason | |---|---| | `O` (Organization) | Identity claim — not proven by ACME | | `OU` (Organizational Unit) | Identity claim — not proven by ACME | | `DC` (Domain Component) | AD-specific — can influence authentication | | `L`, `ST`, `C` | Identity/location claims | | Any custom RDN | Not justified by DNS validation | In Windows and ADCS environments, Subject fields influence certificate-to-account mapping and authentication flows. Allowing arbitrary Subject attributes reintroduces identity confusion and privilege escalation risk. ## Subject Alternative Name Rules - SAN **must** be present - SAN entries **must** be `dNSName` only - DNS names **must** match ACME-validated identifiers ### Forbidden SAN types | Type | Reason | |---|---| | `otherName` (UPN / msUPN) | Enables AD account impersonation | | `rfc822Name` (email) | Identity claim | | `uniformResourceIdentifier` | Not proven by ACME | | `iPAddress` | Not validated via DNS challenge | ## Extension Rules ### Allowed extensions | Extension | OID | Constraint | |---|---|---| | Subject Alternative Name | `2.5.29.17` | DNS names only, no duplicates | | Extended Key Usage | `2.5.29.37` | Server Authentication (`1.3.6.1.5.5.7.3.1`) only | CSR-provided EKU values are ignored or rejected. The EKU that ends up in the certificate comes from your ADCS template — configure it to grant Server Authentication only. ### Forbidden extensions All extensions not in the allow-list are rejected, including: - `Any Purpose` EKU - `Client Authentication` EKU - `Smartcard Logon` - `IP Security` EKUs - `Certificate Policies` - `Name Constraints` - `Authority Information Access` - `CRL Distribution Points` - Microsoft-specific extensions ## CSR Structural Validation To prevent ASN.1 smuggling and parsing ambiguity: - Exactly one `extensionRequest` attribute (`1.2.840.113549.1.9.14`) - No other CSR attributes - Exactly one SAN extension - No duplicate extensions - No trailing or unused ASN.1 bytes - Full DER consumed - Valid CSR signature Any deviation results in rejection. ## Why This Is Not Configurable Security boundaries must be enforced in code. Allowing configuration to relax identity or extension rules would: - Shift responsibility to operators - Increase misconfiguration risk - Complicate audits - Reintroduce known ADCS vulnerabilities Hortval enforces a single safe issuance model. --- ## ADCS ESC Attack Mitigations The enforced rules prevent entire classes of ADCS certificate-based attacks (ESC1–ESC13). ### ESC1 — User-Supplied Subject or SAN with Client Authentication **Attack**: Requester controls Subject or SAN (e.g. UPN) and obtains a certificate usable for AD authentication. **Mitigations**: No user-supplied Subject identity. No `otherName`/UPN in SAN. EKU restricted to Server Authentication only. --- ### ESC2 — Any Purpose EKU Abuse **Attack**: A certificate with `Any Purpose` EKU is used for unintended authentication. **Mitigations**: `Any Purpose` EKU explicitly forbidden. EKU forced to Server Authentication only. --- ### ESC3 — Enrollment Agent Abuse **Attack**: Enrollment Agent certificates allow requesting certificates on behalf of other users. **Mitigations**: - No delegation of enrollment authority — ACME clients never authenticate to ADCS directly *(architectural)* - ⚠️ **Operator responsibility**: do not configure `certificate-template` to point at an Enrollment Agent template. Hortval does not validate the template type. --- ### ESC4 / ESC5 — Dangerous CA or Template Permissions **Attack**: An attacker modifies CA or template permissions to issue malicious certificates. **Mitigations**: - Template selection not exposed to ACME clients — enforced in code, clients cannot influence which template is used - Enrollment runs under the Hortval service account *(architectural)* - ⚠️ **Operator responsibility**: create a dedicated ADCS template for ACME issuance and grant only Enroll permission to the Hortval service account --- ### ESC6 — UPN Injection via SAN **Attack**: A certificate contains a UPN in SAN, enabling authentication abuse. **Mitigations**: `otherName` SAN types explicitly forbidden. DNS-only SAN enforcement. --- ### ESC8 — NTLM Relay to ADCS **Attack**: NTLM authentication to ADCS is relayed to obtain certificates as another identity. **Mitigations**: ACME service does not expose ADCS enrollment endpoints. ACME clients never authenticate directly to ADCS. --- ### ESC9 / ESC10 — Weak or Legacy Certificate Mapping **Attack**: Certificates map to AD accounts via weak identifiers (CN, email, legacy rules). **Mitigations**: No email, UPN, or URI SANs. Minimal Subject. No identity-bearing attributes. --- ### ESC11 — Web Enrollment Abuse **Attack**: ADCS Web Enrollment interfaces abused for unauthorized issuance. **Mitigations**: Web Enrollment not used. Enrollment performed by controlled service account only. --- ### ESC12 — Long-Lived Misissued Certificates **Attack**: Misissued certificates remain valid for long periods. **Mitigations**: - All certificate operations are recorded in the tamper-evident [audit log](../administration/audit.md) (JSONL + HMAC chain) *(enforced)* - ACME protocol supports automated renewal — clients can request new certificates before expiry *(architectural)* - ⚠️ **Shared responsibility**: certificate validity comes from your ADCS template — configure it with a short validity period (30–90 days recommended); Hortval honors the template's validity as-is. See [ADCS hardening & shared responsibility](./hardening.md). --- ### ESC13 — Cross-Forest Certificate Abuse **Attack**: Certificates trusted across forests allow lateral movement. **Mitigations**: EKU restricted to Server Authentication. No user or machine authentication EKUs. No identity-bearing Subject or SAN fields. --- ## Lifecycle Protections The rules above apply at issuance time. Two additional protections operate around the certificate's lifetime: ### Anti-DoS: Pending Authorizations Cap Clients that create orders but never finalize them leave behind pending `acme_authorizations` rows. Without a cap, this is a silent storage-growth DoS. Hortval refuses new orders when the account already has too many in-flight pending authzs. | Property | Default | Configurable | |---|---|---| | Max in-flight | 30 | `rate-limiting.pending-authorizations.max` | | Disable | — | `rate-limiting.pending-authorizations.enabled: false` | The default of 30 is calibrated for the typical "one machine = one ACME account" model where a real client rarely has more than 5–10 pending authzs at once. Expired authzs are excluded from the count so abandoned orders don't lock the account out forever. See [Rate Limiting](../configuration/rate-limiting#pending-authorizations). ### Anti-Misconfig: Failed Validation Limit A misconfigured ACME client (broken DNS, port 80 closed, wrong TLS-ALPN) will keep retrying validations indefinitely, burning CA worker capacity. Hortval keeps an in-memory counter per `(account, hostname)` and refuses new authorizations once that counter is at cap. | Property | Default | Configurable | |---|---|---| | Cap | 5 failed validations | `rate-limiting.failed-validation.max-per-window` | | Window | 1h | `rate-limiting.failed-validation.window` | | Disable | — | `rate-limiting.failed-validation.enabled: false` | The counter decays continuously, so a transient outage that produces a few failures clears within minutes. The check at order-creation time is non-consuming — only actual challenge failures count. See [Rate Limiting](../configuration/rate-limiting#failed-validation). ### Anti-Runaway: Duplicate Certificate Limit A misconfigured or compromised ACME client can loop on the same domain and burn through CA resources — the "2000 certs for one site" failure mode. Hortval caps repeat issuance of the same FQDN set per account within a rolling time window. | Property | Default | Configurable | |---|---|---| | Cap | 5 issuances | `rate-limiting.duplicate-certificate.max-per-window` | | Window | 168h (7 days) | `rate-limiting.duplicate-certificate.window` | | Disable | — | `rate-limiting.duplicate-certificate.enabled: false` | The set is canonicalised (lowercased, sorted, deduplicated, wildcards preserved) and hashed; the count uses an indexed DB lookup. **Revoked certificates are excluded** so legitimate post-revocation reissuance is not blocked. When the limit is hit, the response is HTTP 429 with a precise `Retry-After` (the moment the oldest in-window certificate falls out of the window). See [Rate Limiting](../configuration/rate-limiting#duplicate-certificate). ### Forced Renewal via ARI Hortval implements ACME Renewal Information (RFC 9773). For a **revoked** certificate, the suggested renewal window collapses to `[now, now]`, instructing compliant clients (recent certbot, acme.sh, lego, Caddy, Traefik) to renew immediately. This makes revocation a usable rollover tool for key compromise, template misconfiguration, or rotation. For non-revoked certificates, ARI spreads renewals across a configurable window in the last third of the certificate's lifetime, avoiding thundering-herd reissue across thousands of clients. See [Renewal Information](../configuration/renewal-info). --- # security/dependencies.md --- sidebar_position: 2 title: Dependencies & SBOM --- # Dependencies & SBOM Hortval is a Go binary with a small, auditable dependency tree. This page lists the direct runtime dependencies, explains how to generate a Software Bill of Materials (SBOM), and covers compliance requirements under the EU **Cyber Resilience Act (CRA)** and **NIS2 Directive**. ## Direct Dependencies | Package | Purpose | License | |---|---|---| | `github.com/miekg/dns` | DNS resolver for challenge validation | BSD-3-Clause | | `golang.org/x/crypto` | TLS, PKCS8, cryptographic primitives | BSD-3-Clause | | `golang.org/x/net` | HTTP/2, IDNA, DNS utilities | BSD-3-Clause | | `golang.org/x/sync` | Concurrency primitives | BSD-3-Clause | | `modernc.org/sqlite` | SQLite driver (pure Go, CGO-free) | MIT | | `github.com/lib/pq` | PostgreSQL driver | MIT | | `github.com/microsoft/go-mssqldb` | SQL Server driver | BSD-3-Clause | | `gopkg.in/yaml.v3` | YAML configuration parser | MIT / Apache-2.0 | | `github.com/google/uuid` | UUID generation | BSD-3-Clause | | `github.com/shopspring/decimal` | Decimal arithmetic (SQL Server) | MIT | | `github.com/dustin/go-humanize` | Human-readable sizes in logs | MIT | | `github.com/mattn/go-isatty` | Terminal detection for log formatting | MIT | All dependencies are **open source** with permissive licenses (MIT, BSD, Apache 2.0). No GPL or LGPL dependencies are included. ## Transitive Dependencies The full transitive dependency graph is recorded in each module's `go.sum` file. To list all dependencies including transitive ones: ```bash go list -m all ``` To check for known vulnerabilities: ```bash # Install govulncheck go install golang.org/x/vuln/cmd/govulncheck@latest # Run against the binary or source govulncheck ./... ``` ## Generating an SBOM ### CycloneDX (recommended) [CycloneDX](https://cyclonedx.org) is the format required by most regulatory frameworks including CRA. ```bash # Install cyclonedx-gomod go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@latest # Generate SBOM for the cmd module cd cmd cyclonedx-gomod app -output hortval-sbom.cdx.json -json ``` This produces a machine-readable SBOM listing all dependencies with version, hash, and license information. ### SPDX ```bash # Install syft curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin # Generate SPDX SBOM from the binary syft hortval.exe -o spdx-json > hortval-sbom.spdx.json ``` ### Go native ```bash # Export dependency graph as JSON go list -m -json all > sbom-deps.json ``` ## CRA & NIS2 Compliance ### EU Cyber Resilience Act (CRA) The CRA (applicable from 2027) requires software vendors to: - Maintain and publish an SBOM for each release - Track and remediate known vulnerabilities (CVEs) within defined timelines - Provide a vulnerability disclosure policy - Document security properties of the software **Hortval approach:** - SBOM generated per release using `cyclonedx-gomod` - Dependencies monitored via `govulncheck` in CI - Vulnerability reports accepted at [security contact on hortval.com](https://hortval.com) ### NIS2 Directive NIS2 applies to operators of essential and important entities. If your organization falls under NIS2, deploying Hortval for internal certificate automation contributes to: - **Supply chain security**: all dependencies are open source and auditable - **Incident response**: tamper-evident audit log (JSONL + HMAC chain) records all certificate operations - **Patch management**: single binary deployment simplifies updates ### Go Supply Chain Security Go's module system provides strong supply chain security guarantees: - **Reproducible builds**: `go.sum` records cryptographic hashes of every dependency - **Module transparency log**: the Go checksum database (`sum.golang.org`) independently verifies module hashes - **No runtime package loading**: all dependencies are compiled into the binary — no dynamic loading, no plugin injection surface To verify the binary was built from unmodified sources: ```bash go mod verify ``` ## Minimal Footprint Hortval is designed for a minimal attack surface: - **Single binary** — no installer, no runtime dependencies, no package manager - **Minimal outbound network** at runtime — your own ADCS and DNS servers, plus Hortval's licensing backend over HTTPS in the default online mode (`license: offline: true` removes it entirely). See [Outbound connections & licensing](./outbound-connections.md). - **No telemetry** — Hortval does not call home - **Standard library first** — cryptographic operations use Go's standard `crypto/x509` and `crypto/tls`; no custom crypto implementations --- # security/hardening.md --- sidebar_position: 99 title: ADCS hardening & shared responsibility --- # ADCS hardening & shared responsibility Hortval bridges ACME to your existing PKI; it does not replace your AD CS hardening. Several well-known AD CS misconfiguration classes (the "ESCx" escalation paths) live **in the CA and template configuration you operate**, not in Hortval. This page maps those boundaries so you can review them with your PKI/AD team during deployment — what Hortval handles, what stays on your side, and where an optional Hortval-side check could be added for defense-in-depth. None of the items below are Hortval defects; they are properties of the surrounding environment worth confirming together. --- ## Certificate validity periods **Where it's handled**: for ADCS authorities, certificate validity comes entirely from the ADCS template. Hortval honors it as-is (the 90-day limit only applies to the built-in `fake` test PKI). **To confirm together**: that your templates issue appropriately short-lived certificates for the use case. **Optional Hortval-side check** (on request): a `max-validity` limit on the authority / issuance-policy that refuses a certificate whose `NotAfter` exceeds it. --- ## Extended Key Usage on the issued certificate (incl. ESC3) **Where it's handled**: the EKUs on the issued certificate are defined by the ADCS template, not by Hortval. Hortval constrains what an ACME **client** may request in its CSR (Server Authentication only; extra EKUs are rejected unless you allow them via `csr.allowed-extra-eku`) — but the template has the final say. If the template itself grants additional EKUs, the issued certificate carries them and Hortval passes it through. A template that hands out an Enrollment Agent EKU (`1.3.6.1.4.1.311.20.2.1`) or Any Purpose is the classic ESC3 case. **To confirm together**: that the template grants only the EKUs the certificate actually needs. This is owned by whoever creates the template. **Optional Hortval-side check** (on request): warn or refuse if the issued certificate carries EKUs beyond an allow-list (e.g. Enrollment Agent). --- ## Service-account enrollment permissions (ESC4/ESC5) **Where it's handled**: architectural on the Hortval side — Hortval enrolls under its own service account (in-process with the native connector, or via `certreq.exe` with `adcs-cli`), so ACME clients never authenticate to ADCS directly. The permissions that account holds on the template are set in AD, which Hortval cannot change or fully verify. **To confirm together**: that the service account has only what it needs (Enroll, plus the Certificate Manager role only if you want CA-side revocation) — not Write / Manage on the template. **Optional Hortval-side check** (on request): a startup check that inspects the template with `certutil -v -template` and warns on excess rights. --- ## Audit logging **Where it's handled**: Hortval ships a dedicated, tamper-evident audit log (JSONL with an HMAC chain, verifiable via `hortval audit verify`) covering account, order, challenge, issuance and revocation events — independent of the application log. See [Audit log](../administration/audit.md). **To confirm together**: retention, off-host shipping and access control for that log file, which remain on your operational side. --- ## Enforced entirely by Hortval (for reference) Handled in Hortval code, no operator action needed: - Subject validation: only empty or `CN=` accepted (`common/x509v/certificate_context.go`) - SAN validation: DNS names only — no otherName/UPN/email/IP/URI - Client CSR EKU: constrained to Server Authentication; extra EKUs rejected unless explicitly allowed via `csr.allowed-extra-eku` - Template selection: never exposed to ACME clients - CSR structural validation: no ASN.1 smuggling or duplicate extensions --- # security/outbound-connections.md --- sidebar_position: 4 title: Outbound connections & licensing --- # Outbound connections & licensing Hortval is designed to run inside hardened, segmented networks — often right next to your CA. Security teams rightly audit everything that leaves such a host. This page explains the **only** network connection Hortval makes, **why** it exists, and **how to turn it off entirely**. Short version: - In **online mode** (default), the binary's only outbound destination is Hortval's licensing backend (HTTPS, on `hortval.com`). Nothing else. - In **offline mode** (`license: offline: true`), the binary makes **zero** outbound connections. It is fully air-gappable. - It **never** sends your certificates, private keys, the domain names you issue for, your ACME accounts, your DNS configuration, or your audit log. ## Why the connection exists The connection serves one purpose: the **license lifecycle**. A Hortval license is a signed file (`.lic`) that describes what the instance is allowed to do (allowed databases, max CAs, max managed servers, expiry). The backend is the authority that issues and updates that file. The binary contacts it to: 1. **Receive its license** after registration. 2. **Pick up changes you make in the portal — with no action on the box.** This is why the check is periodic: a **plan upgrade** (more CAs, more managed servers, …), a **renewal**, or a **revocation/retirement** done in the portal lands on the running instance automatically, instead of forcing you to copy a new license file by hand. ## How an instance becomes licensed - **From the box (CLI):** an operator runs `hortval license register`. The instance is bound to the license and receives its `.lic`. - **From the portal (web):** an admin links the instance's installation key to a license in the portal UI. The box only **polls**; once the key is linked, the next poll delivers the `.lic` — no register command needed on the box. The recurring polls afterwards are what deliver **upgrades, renewals and revocations** without operator action. ## What is exchanged The binary sends **license-enforcement metrics** (the figures used to verify your running configuration against your plan) and the **machine name** you set at registration. In return it may receive an updated license file. It does **not** send any operational data — specifically never: - certificates, certificate chains, or CSRs; - private keys (CA keys, the server's own key — none); - the domain names / SANs you issue certificates for; - ACME accounts or any ACME protocol data; - your DNS-validation configuration or network topology; - the audit log or database contents. There is no telemetry, analytics, or crash-reporting channel. Licensing is the entire outbound surface. ## Turning it off — offline mode In `config.yml`: ```yaml license: offline: true ``` With `offline: true` the binary builds no licensing client at all: no polling, registration, renewal, or revocation check, in any boot mode. **Zero outbound connections** — the mode for air-gapped or strictly segmented deployments. Trade-offs you accept offline: - You install the license yourself: `hortval license install ` (delivered out-of-band). - No automatic renewal or **plan-upgrade pickup** — apply a new `.lic` manually. - No portal-driven revocation/retirement of the instance. ## Verifying it yourself The surface is small and deterministic, so you can confirm it: point a network monitor at the host — in online mode you see only HTTPS to `hortval.com`; in offline mode, nothing at all. The binary is reproducible and its dependencies are published — see [Dependencies & SBOM](./dependencies.md) and [Verifying release binaries](./verifying-binaries.md). --- # security/verifying-binaries.md --- sidebar_position: 3 title: Verifying release binaries --- # Verifying release binaries Every Hortval release ships three platform binaries (Linux amd64, macOS arm64, Windows amd64) and a `SHA256SUMS` file: | File | Purpose | |---|---| | `SHA256SUMS` | One SHA-256 hash per binary, one line each. | `SHA256SUMS` covers **integrity** — it lets you detect a corrupted download or a tampered binary if the `SHA256SUMS` file you used was the one published on the official release page. ## Authenticity — Windows, from v0.9.5 **The Windows binary is signed with Authenticode**, and the signature is timestamped (RFC 3161). Timestamping is what keeps it valid after the signing certificate expires: Windows then verifies *"this signature was valid when it was applied"*, so a binary released today does not become unsigned a year from now. Check it before running anything — on every install, including binaries that reached you through an internal mirror or a deployment tool: ```powershell Get-AuthenticodeSignature .\hortval-vX.Y.Z-windows-amd64.exe | Format-List Status,SignerCertificate ``` `Status` must read `Valid`, and the signer must be **SAFE PIC TECHNOLOGIES**. A `NotSigned` or `HashMismatch` means the file is not the one that was published — re-download it. :::caution Signed does not mean SmartScreen goes away It still appears. What changes is that the dialog now shows the **publisher's name** instead of *"Unknown publisher"* — which is the thing you can actually check. SmartScreen's reputation is built from download volume, not from the certificate, so a freshly published release warns regardless. **The prompt is expected. It is not evidence that your download was tampered with** — the signature check above is. See [Antivirus & EDR](../administration/antivirus-edr.md#windows-smartscreen--application-control) for when the prompt appears at all, and for AppLocker rules. ::: ### The publisher name is behind a click The first screen — *"Windows protected your PC"* — shows **no publisher and a single button, `Don't run`**. Taken at face value it says nothing about who signed the binary, and there is no way to proceed. ![SmartScreen, first screen: the warning, a More info link, and a single Don't run button](/img/screenshots/smartscreen-1-blocked.png) Click **More info**. The dialog then shows the two lines that matter, and a second button: ![After More info: App and Publisher lines, with Run anyway alongside Don't run](/img/screenshots/smartscreen-2-publisher.png) **`Publisher` must read `SAFE PIC TECHNOLOGIES`.** If it reads *"Unknown publisher"*, the file you are about to run is not the one that was published — stop there and re-download from the official release page. An operator who never clicks *More info* sees only a refusal, which is why this page exists. ### On a hardened server there is no `Run anyway` If your servers carry the **Microsoft Windows Server security baseline** — or any policy setting *Configure Windows Defender SmartScreen* to **"Warn and prevent bypass"** — the override button is removed on purpose. *More info* still shows the publisher, so the identity check above still works, but there is nothing to click through: ![The same dialog on a hardened server: App and Publisher are shown, but only Don't run is offered](/img/screenshots/smartscreen-3-publisher-no-bypass.png) That is a policy decision, not a fault in the download, and it is a reasonable one. Two ways forward, both normal administration: - **Clear the Mark of the Web**, which is what raises the prompt in the first place: *Properties → Unblock* on the file, or `Unblock-File .\hortval.exe` in PowerShell. A file that never carried the mark — fetched with `curl.exe`, copied from an internal share, deployed by your distribution tool — never reaches this dialog at all. - **Allow it in AppLocker or WDAC** with a publisher rule, which is available precisely because the binary is signed. The registry equivalent of that policy, if you need to confirm what a machine is running: `HKLM\SOFTWARE\Policies\Microsoft\Windows\System` with `EnableSmartScreen = 1` and `ShellSmartScreenLevel = Block`. ## Unblocking the download **Do these in order.** Unblocking is not a way around a security control — it is you asserting where the file came from. That assertion is only worth anything once you have checked the signature, so check it first. **1. Verify the signature** (this is the actual control): ```powershell Get-AuthenticodeSignature .\hortval-vX.Y.Z-windows-amd64.exe | Format-List Status,SignerCertificate ``` `Status` must be `Valid` and the signer **SAFE PIC TECHNOLOGIES**. If it is not, stop — nothing below applies to a file you cannot identify. **2. Remove the Mark of the Web.** Either way works, and both do the same thing: ```powershell Unblock-File .\hortval-vX.Y.Z-windows-amd64.exe ``` or in Explorer: right-click the file → **Properties** → *General* tab → at the bottom, tick **Unblock** → **OK**. ![File properties: the Security line explains the file came from another computer, with an Unblock checkbox](/img/screenshots/unblock-properties.png) The checkbox is only there while the mark is, so its absence means there is nothing to remove. For a folder of files: `Get-ChildItem *.exe | Unblock-File`. **3. Confirm the mark is gone:** ```powershell Get-Item .\hortval-vX.Y.Z-windows-amd64.exe -Stream * ``` `Zone.Identifier` should no longer be listed. The binary now starts without a prompt — including on a server where *Run anyway* was never offered. :::warning No prompt is not a verification The prompt comes from how the file was fetched, not from what it is. Pulling the release with `curl.exe`, `Invoke-WebRequest`, an internal mirror or your software distribution tool leaves no Mark of the Web — so Windows stays silent, and **nothing was checked**. Steps 2 and 3 become unnecessary; step 1 does not. That is the case where verifying matters *most*, not least. An internal channel is precisely where a substituted binary travels without ever meeting a warning, and the silence reads exactly like an approval. Hortval runs on a host that enrols certificates against your CA. Treat its binary the way you treat anything else you put on a Tier-0 machine: check the signature every time, whatever it arrived through. ::: :::note Linux and macOS: integrity only Those binaries are not signed, and no GPG-signed `SHA256SUMS.asc` ships. For them, `SHA256SUMS` is authoritative only insofar as you trust the channel you fetched it from — the official Releases page over HTTPS. Distribution through signed package repositories, where the package manager verifies without anyone having to ask, is the direction being considered rather than a detached signature nobody downloads. ::: ## Verifying a downloaded release After downloading the three binaries and `SHA256SUMS` into the same directory: ```bash sha256sum -c SHA256SUMS ``` Expected output: ``` hortval-vX.Y.Z-linux-amd64: OK hortval-vX.Y.Z-darwin-arm64: OK hortval-vX.Y.Z-windows-amd64.exe: OK ``` If any line says `FAILED`, do not run the corresponding binary — re-download it from the official Releases page. ### Windows users without sha256sum On Windows, use the built-in `certutil`: ```powershell certutil -hashfile hortval-vX.Y.Z-windows-amd64.exe SHA256 ``` Compare the printed SHA-256 against the matching line in `SHA256SUMS`. ## What goes wrong, and what to do | Output | Meaning | Action | |---|---|---| | `sha256sum: WARNING: 1 computed checksum did NOT match` | One of the binaries was modified or truncated. | Re-download the failing binary from the official Releases page. | | `sha256sum: no properly formatted SHA256 checksum lines found` | You're checking the wrong file, or it was corrupted. | Re-download `SHA256SUMS` itself. | --- # upgrading.md --- sidebar_position: 1 title: Upgrading --- # Upgrading from an earlier 0.9.x This page lists every configuration change between **v0.9.1** and **v0.9.5** that requires you to edit your `config.yml`, and the ones that change behaviour without saying so. Read it before replacing the binary. :::info The product was renamed in v0.9.5 Releases up to v0.9.4 were called **Certeasy**; from v0.9.5 the product, the command and the binary are **`hortval`**. This page uses the new name for present-tense behaviour and the old one when it names something you already have on disk. Nothing inside the ACME protocol carries the name, so your ACME clients are unaffected. ::: One thing makes an upgrade here different from most: **the configuration file carries no version number, and it is never rewritten for you.** There is no migration step and no compatibility shim. A key that no longer exists is not ignored — the file fails to parse and the server does not start. Before any upgrade, back up **the database, the installation directory and the configuration**. Keep the database copy even for a release that does not seem to need one: it is the only part you cannot rebuild. ## Check before you switch The new binary can inspect your existing configuration without touching your deployment. Download it, put it somewhere temporary, and point it at the configuration the running service uses: ```bash ./hortval validate -f /etc/certeasy/config.yml ``` `validate` refuses exactly what `serve` refuses — that parity is guaranteed and tested as of 0.9.4 — so a clean run means the file will load. Nothing is written, no database is touched, and the running service is unaffected. :::caution `validate` runs as you, the server runs as its service account From 0.9.5 it also looks at the filesystem — does the working directory's parent exist, is an installation already sitting where a path resolves. That check answers *"can I see this?"*, never *"will the server be able to"*: you are typing the command, the service runs under another account. So a failure to read a directory is reported as a **warning**, not a verdict, and nothing checks writability — guessing another identity's permissions produces confident wrong answers. `validate --no-disk` skips the filesystem entirely, for a config-only check on a machine that will not run the server. ::: Work through whatever it reports, then swap the binary. :::note Coming from 0.9.1 `validate` was introduced in 0.9.2, so your *installed* binary does not have it. Use the new one as shown above — that is the point of the check. ::: ## Configurations that no longer load Each of these stops startup. `validate` reports them all. ### Remove `audit.rotate.max-backups` The key no longer exists, and a file still containing it is refused. Deleting an audit log by file count is not a setting: the log is a compliance artifact and the chain that makes it tamper-evident cannot survive an eviction. Retention will return measured in **duration**, which is the unit a compliance requirement is written in. ```yaml audit: rotate: max-size-mb: 100 max-backups: 10 # delete this line ``` This is the only configuration key ever removed. ### Give every DNS validation profile a `resolved-ip-policy` A profile with neither `allow-cidrs` nor `deny-cidrs` is refused. An empty policy accepted every address DNS returned, link-local and cloud metadata (`169.254.169.254`) included. Unset is not the same as deliberately open, so it is refused rather than defaulted. ```yaml dns-validation-profiles: - name: internal resolved-ip-policy: allow-cidrs: - "10.0.0.0/8" - "192.168.0.0/16" ``` Use `allow-cidrs` with `0.0.0.0/0` and `::/0` if you accept any address on purpose. This applies to **every** profile whatever challenge you use — challenge types are not restricted per profile, so a profile that has only ever served `dns-01` still offers the paths this policy guards. Configurations produced by `certeasy init`, and the shipped examples, already carry the block. ### Complete any `fake` authority That driver used to parse its configuration without checking a single field, so bad values were accepted and failed later — after the database had been migrated — or produced a CA that expired the moment it was created. Now refused: an empty `common-name`, an empty `password`, a `key-size` below 2048, or a non-positive `validity`. `key-size` must also be at least as strong as the strongest `min-rsa-bits` among the issuance policies bound to that authority. A CA weaker than the certificates it signs is a contradiction the server no longer accepts. ### Remove explicit zeros A `0` written deliberately used to mean "no limit" in places where it silently disabled a protection. It is now refused, and you get the default by omitting the key instead. | Section | Keys | |---|---| | `server` | `read-header-timeout`, `read-timeout`, `write-timeout`, `idle-timeout`, `shutdown-timeout`, `max-body-bytes` | | `rate-limiting` | any quota under a section left `enabled: true` | | `renewal-info` | `window-width`, `retry-after` | `renewal-info.lifetime-fraction` must additionally sit strictly between 0 and 1. Out-of-range values used to be clamped to two thirds without a word. The `server` timeouts are worth a special look: before 0.9.4 their defaults were computed into a copy that was then discarded, so a deployment that never set them ran with **no deadlines at all**. Setting them correctly now is not a regression, it is the first time the setting takes effect. ### Fix values that were never validated | Key | Now required to be | |---|---| | `logs.output` | `stderr`, `stdout` or `file` — and `file` requires `logs.file` | | `license.proxy-url` | a parseable `http`/`https` URL with a host | | `license.timeout` | non-negative | | `database.driver` | a driver that exists | | `authorities[].configuration.ca-name`, `.certificate-template` | free of control characters and colons, not starting with a dash | | `authorities[].configuration.certreq-path`, `.certutil-path` | absolute, or a bare file name | Most of these were caught eventually — at the first online licence check, or after the working directory and node marker had already been created. They are now caught before anything happens. For the ADCS binaries, a bare name such as `certutil.exe` resolves from the Windows system directory and keeps working. A path relative to the working directory (`tools\certutil.exe`) is refused. See [ADCS authorities](./configuration/authorities.md). ## Changes that do not stop startup These load fine and behave differently. They are the ones worth reading twice. ### Sections you filled in partially now work Before 0.9.4, a configuration section was parsed into an empty structure, so writing *part* of a section set everything you did not mention to zero: - `audit:` with only `path:` meant `enabled: false` — **the audit log was off** - `rate-limiting:` with only `whitelist:` meant every quota was zero — **every limiter was off** - `database:` omitted entirely meant a nil configuration, which failed a cold start Defaults are now applied first and your values written over them. If you were affected, the upgrade turns these protections **back on**, and that is a change in behaviour on your deployment even though you edited nothing. Check whether your audit volume and your rate limits are what you intended. ### Rate limit ceilings see more traffic The per-IP ceiling now applies to every endpoint rather than four of them, so its defaults were raised to match: `requests-per-minute` from 200 to 1200, `burst` from 20 to 100. **If you tuned these values down, revisit them** — they now meter a much larger population of requests, and a single 3-name certificate costs at least 18. `account-creation` moves from 5/hour burst 2 to 10/hour burst 10. ### Log and audit files became naming bases With rotation enabled, `logs.file` and `audit.path` name a *series*, not a file. Segments are written beside them as `...` and are never renamed. Two consequences: - **Point log collectors at the containing folder with a `*.log` pattern**, not at the path in `file`. Existing `logrotate` rules on `logs.file` become inert. - **Remove any `logrotate` rule targeting `audit.path`.** External rotation of the audit file is no longer supported at all: a third party renaming or truncating it breaks the tamper-evident chain, and the server cannot tell that apart from tampering. Earlier documentation recommended `copytruncate` here; that recommendation was wrong and is withdrawn. While you are editing these two keys, write them as **absolute paths**. A relative one resolves against the process working directory, not against `workdir` — this page's predecessor claimed otherwise. See [Configuration overview](./configuration/overview.md) for what that costs on a Windows service. `logs.rotate.max-backups` also changed default from `0` to `5`. At `0` — the old default — enabling rotation discarded history at every turn. ### Your ADCS connector may have changed underneath you From 0.9.2, `type: adcs` means the **native** connector: in-process COM, no `certreq.exe`, no child process. Configurations naming `adcs` were switched over without a word at upgrade time. If you need the old behaviour — an EDR policy that only trusts the signed Microsoft binaries, for instance — say so explicitly: ```yaml authorities: - name: corp-ca type: adcs-cli # was the meaning of `adcs` before 0.9.2 ``` `adcs-native` is available as an explicit spelling of the new default. See [Antivirus and EDR](./administration/antivirus-edr.md) for which one suits your host. In the same release, `default-timeout` started being honoured. A bug meant it was overwritten with 30 seconds whenever `cert-util-timeout` was unset, which was the normal case; requests that used to be cut off at 30 seconds now run to the configured value, or to the 4-minute default. ## Moving to 0.9.5 Everything above still applies — 0.9.5 adds no new keys and removes none. What it changes is the *name*, and where paths are allowed to point. ### You can still go back The symmetry noted at the top of this page stops at 0.9.4. **A configuration fixed for 0.9.5 still loads on 0.9.4**, provided you wrote absolute paths — which is what the startup message hands you, ready to paste. Only `%CONFIGDIR%` closes the door, and it appears solely in configurations generated by `hortval init`, that is on fresh installs, which have nothing to roll back to. No schema migration ships in this release either. Worth knowing before you upgrade, not after: the moment you want a rollback is rarely the moment you want to discover whether one is possible. ### The command is `hortval` Update every invocation: the `ExecStart=` of a systemd unit, the `binPath=` of a Windows service, your runbooks, your monitoring. Your `config.yml` keeps its name and every key it has. ### Every path must be absolute or anchored A relative path is now refused at startup and by `validate`, in all eight settings that name one: `workdir`, `database.path`, `audit.path`, `logs.file`, `local-pki-cache-dir`, `letsencrypt.cache-dir`, and a bundle's `local-cert-file` / `local-key-file`. Write an absolute path, or anchor it: `%WORKDIR%` in any of them except `workdir` itself, which takes `%CONFIGDIR%` — the directory holding the configuration file. ```yaml workdir: "%CONFIGDIR%/workdir" database: path: "%WORKDIR%/db.sqlite" ``` The refusal names the setting, and when a live installation sits where the value resolved, it prints the exact line to write. This one catches more deployments than it looks. A configuration generated by the 0.9.4 wizard has `workdir: ./workdir`, and the shipped examples used `.workdir` — the documented onboarding path produced exactly the file that now fails. ### `workdir` is required, and there is no default Set it to an absolute path. A configuration that omits it does not start. ```yaml workdir: 'C:\ProgramData\hortval' # or /var/lib/hortval ``` The reason is the same one behind the rule above: this key decides where the database, the node identity, the audit log and the CA key live, and that is not something the binary should pick for you. It was the only path setting still allowed to be guessed. **The refusal tells you where your data is.** Until v2, startup looks at the locations previous releases used — including the pre-rename `certeasy` ones — and names any installation it finds, with the exact line to paste. If both hold one, it says so and lets you choose. Nothing is moved for you. An installation is recognised by its `server_id` marker. A directory holding only a `logs/` folder is not one: a service that started once and failed leaves exactly that. ### Configuration is no longer looked for in the current directory It never should have been: running a command from any directory a less privileged account could write to loaded *that* account's configuration, and the configuration selects the database, the working directory, the audit destination and the outbound proxy. Two candidate files is now a startup error too, rather than a silent first-wins. **Pass `-f `** and neither concerns you. The `certeasy` configuration directories are still read, with a warning, until v2. ### Per-service log levels: two names changed, and an unknown one now stops startup If you set `logs.services`, check it. Two services were renamed, dropping the product name they carried: | Before | Now | |---|---| | `Certeasy-acme-server` | `acme-server` | | `cert-easy-main` | `main` | **There is no alias.** A configuration carrying an old name is refused at startup and by `validate`, and the message lists the accepted names. That refusal is the point. Until 0.9.5 an unrecognised key was **silently ignored** — the level you asked for quietly fell back to the global default, and the only symptom was logs quieter than you expected, at the moment you were trying to diagnose something. A key that never applies is worse than a missing one, because nothing distinguishes it from a working setting. The examples on [Logging](./administration/logging.md) used the old name, so a configuration copied from that page will not start. One edit, once. ### Two more, easy to miss - **Allow egress to `api.hortval.com`.** The licensing backend moved. If your egress is filtered by hostname, an upgrade without this slides the deployment into its degraded licence states. - **A `letsencrypt` bundle now always serves ECDSA P-256.** Before, the first client to connect decided the key type for everyone. If a client of yours needs RSA, use `mode: pki` or `mode: files`, where `key.type` is yours to set. ## What changed in which release | | 0.9.1 → 0.9.2 | 0.9.2 → 0.9.3 | 0.9.3 → 0.9.4 | 0.9.4 → 0.9.5 | |---|---|---|---|---| | Keys removed | — | — | `audit.rotate.max-backups` | — | | Values now refused | zone `protocol` | — | 11 groups, see above | every relative path, unknown `logs.services` keys | | Behaviour changed | `adcs` connector, `default-timeout` | — | partial sections, rate limits, rotation | command name, `workdir` now required, config lookup, licence host, Let's Encrypt key type | | Keys added | — | 7, all optional | 5, all optional | — | **0.9.2 → 0.9.3 requires no configuration change.** It only added optional keys: `disable-ca-revocation`, the certificate manager's `key` block, and a fake authority's `certificate-validity`. The one break in 0.9.2 is `dns-validation-profiles[].zones[].protocol`: a value other than `udp`, `tcp` or empty was parsed and then ignored, and is now refused. Note also that `tcp` came to mean TCP with **no UDP fallback**, where before the setting did nothing at all. ## After the upgrade Hortval applies additive database migrations on its own at startup and refuses anything that a rollback could not undo, naming what is pending. Nothing in the 0.9.1 → 0.9.4 range is affected — every migration shipped so far is additive — but the contract, the exit codes and the `noddl` workflow are described in [Migrations](./administration/migrations.md). For the full narrative of each release, see the [Changelog](./changelog/index.md).