Files
openvpncertupdate/docs/superpowers/specs/2026-08-15-cert-days-design.md
Vlad Doloman 506787f8fc Add design doc for configurable certificate lifetime
CERT_DAYS setting, --days CLI flag, and a Days field in the TUI cert
form. "default"/"" inherit EasyRSA's own EASYRSA_CERT_EXPIRE so that
deploying this cannot silently shorten certs on an existing PKI.

Records why --days=N beats exporting EASYRSA_CERT_EXPIRE, and why the
local rejection of 0 is a fast-fail mirror of EasyRSA's own gate rather
than a substitute for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 04:51:59 +03:00

7.6 KiB

Configurable Certificate Lifetime — Design

Goal: Let the operator choose how long an issued certificate is valid — as a setting (CERT_DAYS), a CLI flag (--days), and a field in the TUI cert form — while leaving today's behaviour untouched for anyone who doesn't set it.

Status: approved 2026-08-15, ready for an implementation plan.

Background

build_client_full() passes nothing about validity to EasyRSA today, so every certificate inherits EASYRSA_CERT_EXPIRE from the vars file (365 days on the deployment that prompted this). There is no way to issue a shorter-lived cert without editing vars, which changes it for every tool that touches the PKI.

Decisions

The setting

CERT_DAYS = "default"   # lifetime of certs this tool issues, in days.
                        # "default" or "" = leave it to EasyRSA (your vars'
                        # EASYRSA_CERT_EXPIRE). A positive integer overrides
                        # it — e.g. 90.

"default" and "" both mean inherit. This is deliberate: deploying the new version must not silently shorten certificates on an existing PKI. Anyone who wants a fixed lifetime sets a number.

CERT_DAYS joins _OVERRIDABLE_SETTINGS, so an external .conf can set it like any other setting. The repo ships no sample .conf (CONFIG_PATH defaults to <script>.conf next to the script), so "in the external config" means overridable and documented, not a new file.

resolve_cert_days()

Lives in the EASYRSA section beside resolve_ca_passphrase() and follows the same sentinel-resolution shape. Called once in main() after argument parsing.

Input Result
"default", "" "" — omit the flag, EasyRSA decides
90, "90" "90"
"090" "90" — normalised, see below
0, "0" ConfigError
-5, "abc", "9 0" ConfigError

It accepts both int and str because people write either in a .conf.

Two behaviours need justifying:

Rejecting 0 is a fast-fail mirror of EasyRSA's own rule, not a substitute for it. EasyRSA already refuses: --days=0 passes option parsing (zero_allowed=1, easyrsa:6955) and then hits an explicit gate at easyrsa:5701Cannot use --days=0 for command build-client-full. Checking locally only moves the failure somewhere cheaper: in the TUI, to the still-open form instead of after it closes; on the CLI, to before the CA passphrase prompt and a subprocess round-trip. The message says why:

CERT_DAYS must be a positive number of days (EasyRSA rejects 0); got "0"

Normalising leading zeros avoids a baffling error. EasyRSA's validation pattern is *[!1234567890]*|0* (easyrsa:7178), so --days=090 fails with Number expected: '090' — an unhelpful thing to read when you typed nothing but digits. int() then str() sidesteps it.

Passing it to EasyRSA

Use the --days=N global option, appended in build_client_full()'s own argument list:

_base_cmd(...) + [f"--passout=pass:{key_passphrase}"]
               + ([f"--days={days}"] if days else [])
               + ["build-client-full", cn]

Rejected alternative: exporting EASYRSA_CERT_EXPIRE through extra_env, which build_client_full() already uses for the email. set_var is export X="${X-$default}" (easyrsa:6170), so it does respect an existing environment value — but a vars file that uses a plain EASYRSA_CERT_EXPIRE=365 assignment instead of set_var overwrites it silently. --days has no such failure mode: EasyRSA exports it at command dispatch, after vars is sourced. It is also the guarded path — EASYRSA_CERT_EXPIRE=0 reaches openssl ca -days 0 with no gate (easyrsa:2738), while --days=0 is refused.

The flag must not go in _base_cmd(). That helper is shared with gen-crl, where --days sets CRL validity instead (easyrsa:7270).

_run_easyrsa()'s verb detection (next(c for c in cmd[1:] if not c.startswith("-"))) is unaffected: --days=90 starts with -, so build-client-full is still found.

CLI

--days N, a non-exclusive argument alongside --email. Overrides CERT_DAYS for that run of --create or --reissue. Parsed through resolve_cert_days() so a bad value reads the same however it arrives.

Like --email, it is accepted-but-unused with --revoke, --gen-crl and --list* rather than being an error — consistent with the existing parser, which does not police flag combinations beyond its one mutually-exclusive group.

CliRunner.create() and .reissue() gain a days: str = "" parameter, forwarded to _issue() and on to build_client_full().

TUI

The cert form grows a fourth field, in order CN, Email, Days, Password. Fields move to y=3,5,7,9; buttons to y=11; the window grows from 13 to 15 rows, which still fits an 80x24 terminal. CertFormResult gains days: str.

The Days field is digits-only: InputField gains an optional allowed: Optional[str] = None charset that filters insertions in handle_key() — pure logic, directly testable, no curses needed. Empty means inherit, so "default" never has to be typed.

That leaves 0 (and 00, …) as the only reachable invalid input. Submitting it shows the resolve_cert_days() message on the hint line in COLOR_ERROR and keeps the form open, rather than closing the form and failing later.

The field is seeded from the resolved CERT_DAYS: blank when inheriting, otherwise the number.

Data flow

CERT_DAYS (SETTINGS)  ──┐
.conf override        ──┤
                        ├─► resolve_cert_days() ─► "" | "90"
--days N (CLI)        ──┘                              │
                                                       ├─► CliRunner._issue()
TUI form "Days" field ─► resolve_cert_days() ──────────┤
                                                       └─► build_client_full(days=…)
                                                                   │
                                                       --days=90 ──┘  (omitted when "")

Error handling

Failure Where it surfaces
Bad CERT_DAYS in .conf or SETTINGS ConfigError in main(), printed and exit 1 — same path as other config errors
Bad --days on the CLI ConfigError at parse time, printed and exit 1
0 typed in the TUI form Hint line in COLOR_ERROR, form stays open
Anything EasyRSA still rejects EasyRSAError with EasyRSA's own text, now legible via _easyrsa_diagnostics()

Testing

  • resolve_cert_days() table: valid, both sentinels, 0, negative, junk, int vs str, leading zeros.
  • build_client_full() includes --days=90 when given, omits it when "", and keeps --days out of _base_cmd() (assert revoke_issued/gen_crl commands carry no --days).
  • --days parses and reaches create/reissue; a bad value exits 1.
  • CERT_DAYS survives an external .conf override (extend the existing _OVERRIDABLE_SETTINGS test).
  • InputField drops characters outside allowed and keeps existing behaviour when allowed is None.
  • show_cert_form() returns days, seeds the field from the setting, and refuses to submit 0.

Each verified by mutation — revert the logic, confirm the intended tests go red — as with the two preceding changes on this branch.

Out of scope

  • Changing the lifetime of an existing certificate (that is easyrsa renew, a different workflow).
  • CA lifetime (EASYRSA_CA_EXPIRE) and CRL validity (--days for gen-crl).
  • Warning when CERT_DAYS is shorter than DAYS_AHEAD, which would make every new cert appear in the expiring list immediately. Worth revisiting if it bites in practice.