diff --git a/.claude/docs/architecture.md b/.claude/docs/architecture.md index de1960d..7f16380 100644 --- a/.claude/docs/architecture.md +++ b/.claude/docs/architecture.md @@ -32,6 +32,7 @@ Certificate/key processing, in-memory storage, and persistence. Used by both CLI - `process.go` — `ProcessData()`: format detection and parsing pipeline (PEM → DER → PKCS#7 → PKCS#8 → SEC1 → Ed25519 → JKS → PKCS#12). Calls `CertHandler` for each parsed item. - `memstore.go` — `MemStore`: in-memory `CertHandler` implementation and primary runtime store. `CertRecord`/`KeyRecord` types. Stores multiple certs per SKI via composite key (serial + AKI). Provides `ScanSummary()`, `AllCertsFlat()`, `AllKeysFlat()`, `CertsByBundleName()`, `BundleNames()`, `DumpDebug()`. - `summary.go` — `ScanSummary` struct for aggregate scan counts, including roots/intermediates/leaves/keys/matches plus expired, Mozilla-trusted, system-trusted, and untrusted certificate totals. +- `export_formats.go` — Supported artifact selection and minimal managed-export defaults; key-free artifacts do not require a private key. - `export.go` — `GenerateBundleFiles()`: creates all output files for a bundle (PEM variants, key, P12, K8s YAML, JSON, YAML, CSR). All key output is normalized to PKCS#8 format. `BundleExportInput` and `ExportMatchedBundleInput` support an `EncryptKey` option for PKCS#8 v2 password-protecting exported `.key` files. `GenerateJSON`, `GenerateYAML`, `GenerateCSR` also exported individually. `BundleWriter` interface and `ExportMatchedBundles()` provide shared export orchestration for both CLI and WASM. - `validate.go` — Certificate validation checks. `RunValidation()` orchestrates all checks for a certificate. `CheckExpiration()`, `CheckKeyStrength()`, `CheckSignature()`, `CheckTrustChain()` for individual validation steps. Types: `RunValidationInput`, `ValidationResult`, `ValidationCheck`, `CheckTrustChainInput`. - `aia.go` — Store-aware AIA resolution. `ResolveAIA()` fetches missing intermediates via AIA URLs using an `AIAFetcher` callback. `HasUnresolvedIssuers()` checks if any certs need issuer resolution. Type: `ResolveAIAInput`. @@ -45,7 +46,11 @@ Certificate/key processing, in-memory storage, and persistence. Used by both CLI CLI business logic and file I/O. Delegates to `internal/certstore/` for processing, storage, and export. No SQLite dependency at this layer. - `crypto.go` — File ingestion pipeline. `ProcessFile()` and `ProcessData()` delegate to `certstore.ProcessData()` with `MemStore` as the handler. Also handles CSR detection for CLI logging. -- `exporter.go` — Bundle export. `ExportBundles()` iterates `MemStore` bundle names, finds matching certs/keys, builds chains via `certstore.ExportMatchedBundles()`. `filesystemWriter` implements `certstore.BundleWriter` to write results to disk with appropriate permissions (0600 for sensitive files). +- `bundleplan.go` — `PlanBundleExports()` selects scoped candidates deterministically, records unselected alternatives, checks keys/validity/trust before replacement conflicts, enforces required bundles, generates selected artifacts in memory, and returns a manifest. It reserves every selected primary directory before candidate lookup, reserves manifest and refresh-lock names, and rejects directory collisions using Unicode normalization and case-insensitive comparison. Control characters, Windows device names, and components longer than 255 bytes are rejected in both directories and generated artifacts. Existing directory names, unselected configuration aliases, and manifest bundle identities protect scoped refreshes even with force enabled; directory names are rechecked under the write lock. Future-dated leaves are always skipped, including with force. Expired leaves require a separate opt-in and retain trust verification at their issuance time. Internal callers can supply `CustomRoots` with the `custom` trust store. `BundleExportPlan.Write()` rechecks each candidate's validity and captured trusted chain before any replacement and immediately before its own write, applies validated plans, and records created/replaced/blocked status. A committed replacement retains its status if backup or lock cleanup fails. Refresh locks use an anchored directory handle and check marker identity before commit and release. Destination changes, lost locks, and scope conflicts at write time block pending entries and invalidate the plan while preserving committed statuses. Failed existing-directory reinspection also blocks the affected entry and preserves both the validation sentinel and underlying inspection cause. +- `bundleplan_existing.go` — Inspects existing bundle leaves, recognizes managed manifests case-insensitively to retain bundle and selected CA identities, and fingerprints contents to reject stale plans and symlinked output bundles. Conflicting manifest identities are rejected. Malformed or incomplete JSON/YAML certificate metadata makes replacement ambiguous even alongside a valid leaf: certificate PEM fields must be nonempty, and manifests require bundle identity and selected leaf PEM. Artifact suffixes and the CSR JSON/Kubernetes YAML exclusions are case-insensitive. Malformed artifact diagnostics retain the filename even when the directory has zero or multiple identifiable leaves. +- `bundlepaths.go` — Rejects config, password, and database paths inside managed output before scan inputs are loaded. Resolves symlinks before parent traversal, including missing destinations and aliases in the working directory. Pins each plan's absolute resolved output and nearest existing directory identity, upgrading the guard when a new output root is created. Checks that identity before writes, under the lock, and before committing staged artifacts. Shares NFC-normalized case comparison with the planner and scan exclusions. +- `scanwalk.go` — Walks eligible scan inputs, excludes declared output/database/password paths using NFC-normalized case comparison of whole components, and checks symlink targets. Canonical exclusions resolve symlinked working directories and existing ancestors of future outputs. In-root symlink containment uses the same canonical absolute paths. Directory exclusions prune their descendants; similarly prefixed sibling directories remain eligible. Scan-root containment uses a separate strict path boundary. +- `exporter.go` — Bundle export. `ExportBundles()` iterates `MemStore` bundle names, finds matching certs/keys, builds chains via `certstore.ExportMatchedBundles()`. `filesystemWriter` implements `certstore.BundleWriter` to write results to disk with appropriate permissions (0600 for sensitive files), using staged replacement and rollback with short temporary/backup names independent of the bundle name. Staging, writes, renames, rollback, and cleanup use opened parent/staging directory handles, preserving cleanup after output-root relocation. Managed writes use a pre-commit callback to recheck cancellation, scope, validity/trust, and existing contents after staging, then write the final manifest. A final guard validates the opened parent against the pinned destination and repeats the safety checks after manifest staging, immediately before the first rename. A distinct wrapped error identifies committed replacements whose backup cleanup failed and includes the retained backup path. - `bundleconfig.go` — YAML config parsing. Supports `defaultSubject` inheritance. Validates `bundleName` values as DNS-1123 labels at load time with line-number error reporting. - `inspect.go` — Certificate/key/CSR inspection. `InspectFile()` and `InspectData()` produce `InspectResult` structs, including top-level certificate extension summaries for certificate records. `ResolveInspectAIA()` fetches missing intermediates for trust annotation. `AnnotateInspectTrust()` records `trust_anchors` / `trust_warnings` and the derived trusted/untrusted status. `FormatInspectResults()` renders text or JSON output. - `verify.go` — Chain validation and diagnostics. `VerifyCert()` checks chains, key matches, and expiry, first assembling intermediates with a non-verifying AIA walk and then probing Mozilla/system/file trust sources explicitly so `trust_anchors` / `trust_warnings` survive even when one source is unavailable; verbose mode also includes top-level certificate extension summaries for the leaf and displayed chain entries. `DiagnoseChain()` analyzes chain failures. `FormatVerifyResult()` and `FormatDiagnoses()` for output. Types: `VerifyInput`, `VerifyResult`, `ChainCert`, `Diagnosis`, `DiagnoseChainInput`. @@ -63,7 +68,8 @@ Thin CLI layer. Each file is one Cobra command. Flag variables are package-level - `main.go` — Entry point. CLI version string, memory limit enforcement, exit code handling (0 success, 1 general error, 2 `ValidationError`). - `root.go` — Root Cobra command with shared flags: `--log-level`, `--passwords`, `--password-file`, `--allow-expired`, `--verbose`. `--verbose` enables extended certificate details such as serials, key/signature metadata, and extension summaries on commands that support it. Registers all subcommands. -- `scan.go` — Main scanning command with `--dump-keys`, `--dump-certs`, `--max-file-size`, `--bundle-path` flags. +- `scan.go` — Main scanning command with `--dump-keys`, `--dump-certs`, `--max-file-size`, `--bundle-path` flags. Declared bundle/dump outputs, database paths, password files, and their symlink aliases are excluded from file ingestion; `--load-db` still explicitly imports the saved inventory. Export config failures are fatal. Bundle assignment runs after AIA resolution, including fetched CA certificates in configured exports. +- `scan_export.go` — Managed bundle CLI: scope, explicit `--write`, `--dry-run`, required bundles, output formats, separate input/output password files, and text/JSON plan rendering. P12 retains the default `changeit` password; PEM key encryption requires an explicit output password. Passes `--allow-expired` independently of `--force` and renders unselected candidate decisions. - `bundle.go` — Build verified certificate chains from leaf certs; resolves intermediates via AIA; outputs PEM, chain, fullchain, PKCS#12, or JKS with `--key`, `--force`, `--trust-store` flags. - `inspect.go` — Display detailed certificate, key, or CSR information with text or JSON output (`--format`); certificate output includes the top-level extension list, JSON certificate entries include `trust_anchors` and `trust_warnings`, and expired items are filtered unless `--allow-expired`. - `verify.go` — Verify certificate chains, key matches, expiry windows, and optional OCSP/CRL status; returns exit code 2 on validation failures; always checks Mozilla + system trust and accepts `--roots` for additional file-backed trust anchors, including pinned or legacy non-CA anchors. Flags: `--key`, `--roots`, `--expiry`, `--diagnose`, `--ocsp`, `--crl`, `--format`. Verbose output includes extension summaries for the leaf and chain entries. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5908c01..326f6e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add scoped managed bundle refresh plans with repeatable `scan --bundle-name` / `--only`, replacement comparisons, per-bundle manifests, `--require-bundle`, `--fail-on-skip`, and selectable `--formats` ([#225]) + - Add `tree` subcommand to display the full CLI command, subcommand, and flag surface in a tree layout ([#169]) - Encrypt PEM private key output (`.key`) using PKCS#8 v2 (AES-256-CBC) when an explicit export password is supplied ([#167]) - Support decryption of PKCS#8 v2 encrypted private keys (`ENCRYPTED PRIVATE KEY` PEM blocks) with all PBES2 cipher (AES-128/192/256-CBC, 3DES-CBC) and PRF (HMAC-SHA-1/256/384/512) combinations ([#167]) @@ -21,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking:** `scan --bundle-path` now previews by default and requires `--write` to save bundles; dry runs never write outputs, and unsafe replacement plans fail before modifying bundles ([#225]) +- **Breaking:** Scan input passwords no longer encrypt outputs; use `--output-password-file` explicitly. Managed P12 output retains the default `changeit` password; Kubernetes YAML, YAML, and CSR files now require explicit format selection ([#225]) + - **Breaking:** Require Go 1.27+ and update Go, web, and development dependencies to current stable releases ([#200]) - **Breaking:** Default `TrustStore` in `DefaultOptions()` changed from `"system"` to `"mozilla"` — pure-Go Mozilla root verification is used by default instead of macOS `SecTrustEvaluateWithError` syscalls, eliminating multi-minute hangs on large certificate stores - Default `scan`, `verify`, `inspect`, and `connect` trust-store selection to Mozilla, and require an explicit `--trust-store system` when command trust reporting should come from host trust roots @@ -35,6 +40,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Recheck the opened output destination and refresh lock after manifest staging, immediately before replacement; invalidate pending entries on destination or lock failures while retaining committed statuses ([#225]) +- Anchor bundle staging, writes, rollback, and cleanup to opened directories so output-root relocation cannot strand private artifacts or redirect cleanup ([#225]) +- Mark failed existing-bundle reinspection as blocked and preserve the underlying inspection error alongside the validation result ([#225]) +- Resolve symlinked working directories and future output ancestors in scan exclusions while retaining valid in-root symlink inputs ([#225]) +- Reject managed directory/artifact components above 255 bytes during planning and use short staging/backup names so accepted long bundle names remain writable ([#225]) +- Require certificate PEM fields and manifest bundle identity during replacement inspection; inspect artifact extensions and CSR/Kubernetes exclusions case-insensitively ([#225]) +- Report refresh-lock cleanup failures, preserve replacement lock markers, and anchor cleanup to the original output directory ([#225]) +- Mark pending exports blocked and invalidate stale plans when directory scope changes before writing, preserving already committed statuses ([#225]) +- Include AIA-fetched certificates in configured bundle exports by assigning names after chain resolution ([#225]) +- Block replacement when existing JSON/YAML certificate metadata is malformed, even alongside a valid leaf, and retain filename-specific diagnostics ([#225]) +- Add managed-export and artifact-inspection context to propagated errors while preserving their underlying causes ([#225]) +- Reject colliding derived bundle names within one configuration rule while preserving deliberate grouping under an explicit `bundleName` ([#225]) +- Pin managed plans to absolute resolved output directories and validate directory identity before writing, preventing working-directory changes or retargeted aliases from redirecting exports ([#225]) +- Include the planned bundle name in errors that occur before staged artifacts reach the commit callback ([#225]) +- Recheck managed directory contents, scope, validity, and trust after artifact staging, preserving concurrent edits and reporting late conflicts before replacement ([#225]) +- Keep specific malformed-artifact filenames in replacement diagnostics instead of replacing them with generic missing or multiple leaf errors ([#225]) +- Exclude case and Unicode aliases of declared output, database, and password paths from scans, including aliased symlink targets ([#225]) +- Reject control characters in managed directory and artifact names during planning, preventing platform-specific write failures after partial refreshes ([#225]) +- Reject an already-canceled managed write before creating output directories or lock markers ([#225]) +- Reverify the planned trusted chain before managed writes, blocking expired intermediates or roots and retaining explicit force and expired-leaf policies ([#225]) +- Preserve replaced bundle status when backup cleanup fails after installation, report the retained backup path, and stop subsequent writes ([#225]) +- Reserve every selected bundle directory before candidate lookup and reject Unicode-equivalent aliases, including when a rule has no matching certificate yet ([#225]) +- Reject Windows device names in generated bundle artifacts even when the configured directory name is safe ([#225]) +- Recheck candidate validity before managed writes, preventing certificates that expire after planning from replacing bundles without explicit `--allow-expired` ([#225]) +- Reject config, password, and database paths inside managed output before scanning, protecting declared files from replacement and keeping database snapshots outside bundle artifacts ([#225]) +- Respect existing refresh-lock markers regardless of filename case, preventing managed writes from bypassing a reserved marker ([#225]) +- Skip not-yet-valid managed bundle candidates even with `--force`, preserving existing bundles and failing required exports ([#225]) +- Reject managed directory names with trailing periods or Windows device names on every platform, preventing normalized directory aliases from overwriting another bundle ([#225]) +- Recognize existing export manifests regardless of filename case, retaining managed bundle and CA identities during refresh and rejecting conflicting manifest identities ([#225]) +- Reserve unselected configuration aliases before the first scoped export, including when the output directory does not yet exist ([#225]) +- Exclude declared database snapshots and their symlink aliases from scan file ingestion while preserving explicit `--load-db` imports ([#225]) +- Preserve unselected existing bundles when a scoped refresh uses a case or sanitized name alias, and recheck directory names before applying the plan ([#225]) +- Identify the operation when loading trust pools fails during managed bundle export, preserving the underlying error ([#225]) +- Reject bundle-directory names that differ only by case, preventing one requested bundle from silently overwriting another on case-insensitive filesystems ([#225]) +- Reserve managed manifest and refresh-lock names during planning, and skip optional untrusted candidates before replacement conflicts can block other exports ([#225]) +- Skip optional expired or keyless bundle candidates before replacement checks, exclude existing dump outputs from scans, and stop managed writes if the default-password warning cannot be displayed ([#225]) +- Ignore CSR JSON when identifying existing bundle certificates, so complete legacy bundles can refresh without a false replacement conflict; report the filename for malformed certificate artifacts ([#225]) +- Keep the managed P12 default-password warning visible regardless of `--log-level` ([#225]) +- Protect managed bundle refreshes against expiration downgrades, equal-expiry certificate conflicts, ambiguous existing leaves, and files changed after planning ([#225]) +- Make certificate selection deterministic using expiry, issuance time, and SHA-256 fingerprint; expose skipped candidates and fail on invalid export configuration ([#225]) +- Honor explicitly selected scan roots named `vendor`, and exclude declared output directories and password files from directory ingestion ([#225]) +- Preserve default P12 exports, exclude symlink aliases of managed output and password files, report unselected certificate candidates, and honor `--allow-expired` independently of `--force` during managed refreshes ([#225]) +- Preserve managed CA identity during refresh, keep duplicate Kubernetes Secret names valid, and allow required bundles to succeed when only historical duplicates are skipped ([#225]) + - Remove Homebrew's deprecated `postflight` warning from stable and nightly casks while preserving macOS quarantine handling ([#200]) - Display ML-DSA signature scheme names when Go 1.27 TLS peers request client certificates ([#200]) - Make `scan` summaries rely on the selected trust store while still retrying bundle exports against host trust roots after Mozilla unknown-authority failures @@ -993,6 +1042,7 @@ Initial release. - Homebrew distribution via GoReleaser [Unreleased]: https://github.com/sensiblebit/certkit/compare/v0.8.3...HEAD +[#225]: https://github.com/sensiblebit/certkit/pull/225 [#200]: https://github.com/sensiblebit/certkit/pull/200 [0.8.3]: https://github.com/sensiblebit/certkit/compare/v0.8.2...v0.8.3 [0.8.2]: https://github.com/sensiblebit/certkit/compare/v0.8.1...v0.8.2 diff --git a/EXAMPLES.md b/EXAMPLES.md index ef93644..54aabfa 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -372,7 +372,7 @@ PKCS#12/JKS exports use the first non-empty password from `-p`/`--password-file` certkit bundle cert.pem --key key.pem --format jks -p "your-password" -o keystore.jks ``` -If no non-empty export password is provided, certkit defaults to `changeit` for PKCS#12/JKS. A warning is emitted on stderr so production exports do not silently rely on the well-known default. +For the single-chain `bundle` and `convert` commands, if no non-empty export password is provided, certkit defaults to `changeit` for PKCS#12/JKS. A warning is emitted on stderr so production exports do not silently rely on the well-known default. --- @@ -451,6 +451,8 @@ Resume from a previous scan: certkit scan /path/to/new-certs/ --load-db inventory.db --save-db inventory.db ``` +Declared `--save-db` and `--load-db` paths are excluded from file ingestion, including their symlink aliases. `--load-db` imports the saved inventory once through the database loader; `--save-db` alone does not import an existing snapshot. + ### Dump all certs or keys to a single file Dump every discovered certificate into a single PEM file: @@ -491,15 +493,55 @@ bundles: - api.example.com ``` -Then scan and export: +Preview a refresh of only `myapp-tls`: + +```sh +certkit scan ./tmp --config ./bundles.yaml --bundle-path ./bundles \ + --bundle-name myapp-tls --input-password-file ./tmp/vendor-password --dry-run +``` + +The preview compares the candidate with the existing leaf and reports serial, fingerprint, validity, source, and trust results. It writes nothing. After reviewing it, repeat with `--write`: ```sh -certkit scan /path/to/certs/ --bundle-path ./bundles -c bundles.yaml +certkit scan ./tmp --config ./bundles.yaml --bundle-path ./bundles \ + --bundle-name myapp-tls --input-password-file ./tmp/vendor-password --write ``` -This creates a directory per bundle with the exported formats certkit supports there: PEM (leaf, chain, fullchain, intermediates, root), private key, PKCS#12, Kubernetes Secret, JSON, YAML, and a CSR for renewal. +Repeat `--bundle-name` to select more bundles. An explicitly selected bundle must be produced; missing certificates, required keys, or trust make the command fail. Public-only formats do not require a private key. Use `--require-bundle myapp-tls` to require a bundle while retaining the default all-configured-bundles scope, or `--fail-on-skip` to require every planned bundle. Config errors and protected replacement conflicts fail before any bundles are written. Malformed or incomplete JSON/YAML certificate metadata blocks replacement even alongside a valid PEM and reports the offending filename, including mixed-case extensions; correct the file or explicitly authorize replacement with `--force`. CA certificates fetched through AIA are also eligible for configured bundles. Generated directory and artifact names must fit a 255-byte UTF-8 filename component; a short `bundleName` does not shorten CN-based artifact filenames. + +Keep `bundles.yaml`, password files, and any `--load-db` / `--save-db` paths outside `./bundles`. Managed refresh rejects control files inside its output tree, including symlink aliases and database paths that do not yet exist. Scan exclusions also resolve symlinked working directories and ancestors of not-yet-created outputs, keeping their contents out of ingestion. + +With `--duplicates`, skipped older candidates do not fail a required or scoped export if the primary bundle is produced. Use `--fail-on-skip` to require every duplicate too. Kubernetes Secret names stay equal to the configured bundle name, even inside dated duplicate directories. + +The default output is PEM variants, a `.key` file, a `.p12` archive, public JSON metadata, and `manifest.json`. Request only the artifacts you need: + +```sh +certkit scan ./tmp -c bundles.yaml --bundle-path ./bundles \ + --bundle-name myapp-tls --formats pem,key,chain,json \ + --input-password-file vendor-password --write --json +``` + +`--json` prints the export manifest, including created/replaced/skipped status and reasons. Every saved bundle also contains `manifest.json`. Existing directories are replaced as a unit; unselected artifacts from a previous export are removed and listed in the plan. + +The manifest reports absolute, resolved output directories. Each in-memory plan pins that destination and checks directory identity, so working-directory changes and retargeted aliases cannot redirect its writes; replacing the resolved destination requires a new plan. Destination and lock checks run again after staging the manifest, immediately before replacement. Failures mark pending entries as blocked while preserving the status of earlier committed bundles. + +`manifest.json` and the output-directory lock `.certkit-refresh.lock` are reserved, including case variants. If the CN is `manifest`, omit `json` from `--formats`. If a CN-derived bundle directory collides with the lock name, set a different `bundleName` in the configuration. Collisions fail during the preview. + +Configured directories must remain distinct after sanitization, Unicode normalization, and case-insensitive comparison, including rules that have no matching certificate yet. Control characters and Windows device names are rejected in both directory names and generated artifact filenames, even on other platforms; a safe `bundleName` cannot make a CN-derived `CON.pem` filename or a filename containing a tab, newline, or NUL portable. + +Scan input passwords never become output passwords. To deliberately create an encrypted key and P12, supply a separate output password file: + +```sh +certkit scan ./tmp -c bundles.yaml --bundle-path ./bundles \ + --bundle-name myapp-tls --formats pem,key,p12,json \ + --input-password-file vendor-password --output-password-file deployment-password --write +``` + +Without `--output-password-file`, P12 uses the intentional `changeit` default with a warning on stderr. Select formats without `p12` to omit the archive. A selected `.yaml` artifact also contains a private key, encrypted only when `--output-password-file` is supplied. Kubernetes TLS secrets (`--formats k8s`) always contain unencrypted keys. + +Expiration downgrades and equal-expiry conflicts are blocked for candidates that pass the key, validity, and trust checks. Optional candidates that fail those checks are skipped before replacement conflicts are considered. `--force` explicitly allows protected replacements **and untrusted certificates**. Future-dated leaves are always skipped, even with `--force`. Expired leaves additionally require `--allow-expired`; with verification enabled, their chains are checked at the leaf's `NotBefore` time, reported in chain warnings. The manifest records force overrides and whether trust verification was disabled. Selection uses latest expiry, latest issuance time, and then a stable fingerprint tie-breaker. Unselected candidates and their reasons appear in the plan and manifest; they do not count as failed bundles for `--fail-on-skip`. -When an export password is supplied via `-p`/`--password-file`, the `.key` PEM output is encrypted and the `.yaml` bundle's `key` field also contains an encrypted PKCS#8 v2 `ENCRYPTED PRIVATE KEY` block. Without an explicit password, those key outputs are written as unencrypted PKCS#8 (`PRIVATE KEY`). Kubernetes TLS secrets always contain unencrypted keys regardless of the password setting. +Validity and the planned trusted chain are checked again before writing, including intermediate and root expiry. If a candidate expires after planning, the write fails unless `--allow-expired` was supplied; expired leaves retain historical chain verification. All candidates are rechecked before the first replacement, and each is checked again after staging, immediately before its own replacement. Existing names and contents are also rechecked then, so edits made during staging are preserved. If a later write fails, the result manifest records bundles already applied, including a replacement whose backup cleanup failed. Cleanup failures identify the retained backup path and stop subsequent writes. --- @@ -669,11 +711,11 @@ For encrypted private keys, PKCS#12, or JKS files, pass passwords with `-p`: # Single password certkit inspect server.p12 -p "mysecret" -# Multiple passwords (tries each one and uses the first explicit one as the default PKCS#12/JKS export password) +# Multiple input passwords (scan never uses these for output encryption) certkit scan /path/to/certs/ -p "secret1,secret2,changeit" -# Passwords from a file (one per line, also used for PKCS#12/JKS export defaults) -certkit scan /path/to/certs/ --password-file passwords.txt +# Input passwords from a file (one per line) +certkit scan /path/to/certs/ --input-password-file passwords.txt ``` certkit always tries empty string, `password`, `changeit`, and `keypassword` automatically -- those cover most default passwords. diff --git a/README.md b/README.md index 620d986..1d64c73 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,19 @@ certkit bundle cert.pem -o chain.pem See [EXAMPLES.md](EXAMPLES.md) for a walkthrough of the main certificate workflows and real-world scenarios. +## Managed bundle refresh + +Use `scan --bundle-path` to turn a vendor delivery into named bundle directories. It shows a plan by default; saving files requires `--write`: + +```sh +certkit scan ./tmp --config ./bundles.yaml --bundle-path ./bundles \ + --bundle-name sentinelone-tls --bundle-name sentinelonev5-tls \ + --formats pem,key,chain,fullchain,intermediates,root,json,yaml \ + --input-password-file ./tmp/vendor-password --dry-run +``` + +Review the selected certificates, source files, validity, trust result, and replacement decisions. Repeat the command with `--write` in place of `--dry-run` to apply it. Add `--json` for a machine-readable export manifest. The `bundle` command remains the single-chain workflow; managed directories are produced by `scan`. + ## Common Commands | Command | What it does | @@ -137,7 +150,7 @@ See [EXAMPLES.md](EXAMPLES.md) for a walkthrough of the main certificate workflo | `--verbose`, `-v` | `false` | Extended details in output (serial, key info, signature algorithm, key usage, EKU, extensions) | -Common passwords (`""`, `"password"`, `"changeit"`, `"keypassword"`) are always tried automatically. +Common passwords (`""`, `"password"`, `"changeit"`, `"keypassword"`) are always tried automatically for input decryption. On `scan`, password flags never select output encryption; use `--output-password-file` explicitly. ### Inspect Flags @@ -256,21 +269,32 @@ Input format is auto-detected. ### Scan Flags -| Flag | Default | Description | -| ------------------------- | ---------------- | -------------------------------------------------------- | -| `--aia-timeout` | `2s` | Timeout for AIA certificate fetches (e.g. 2s, 500ms) | -| `--allow-private-network` | `false` | Allow AIA fetches to private/internal endpoints | -| `--bundle-path` | | Export bundles to this directory | -| `--config`, `-c` | `./bundles.yaml` | Path to bundle config YAML | -| `--dump-certs` | | Dump all discovered certificates to a single PEM file | -| `--dump-keys` | | Dump all discovered keys to a single PEM file | -| `--duplicates` | `false` | Export all certificates per bundle, not just the newest | -| `--force`, `-f` | `false` | Allow export of untrusted certificate bundles | -| `--format` | `text` | Output format: text, json | -| `--load-db` | | Load an existing database into memory before scanning | -| `--max-file-size` | `10485760` | Skip files larger than this size in bytes (0 to disable) | -| `--save-db` | | Save the in-memory database to disk after scanning | -| `--trust-store` | `mozilla` | Trust store: system, mozilla | +| Flag | Default | Description | +| ------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--aia-timeout` | `2s` | Timeout for AIA certificate fetches (e.g. 2s, 500ms) | +| `--allow-private-network` | `false` | Allow AIA fetches to private/internal endpoints | +| `--bundle-name` | | Export only these configured bundle names (repeatable) | +| `--bundle-path` | | Plan bundles in this directory; --write applies the plan | +| `--config`, `-c` | `./bundles.yaml` | Path to bundle config YAML | +| `--dry-run` | `false` | Show the export plan without writing any files | +| `--dump-certs` | | Dump all discovered certificates to a single PEM file | +| `--dump-keys` | | Dump all discovered keys to a single PEM file | +| `--duplicates` | `false` | Export all certificates per bundle, not just the newest | +| `--fail-on-skip` | `false` | Fail the entire export if any requested bundle is skipped | +| `--force`, `-f` | `false` | Allow untrusted bundles and explicitly override replacement conflicts or expiration downgrades | +| `--format` | `text` | Output format: text, json | +| `--formats` | | Bundle artifacts: pem,key,chain,fullchain,intermediates,root,json,yaml,p12,k8s,csr,csr-json (default pem,key,chain,fullchain,intermediates,root,json,p12) | +| `--input-password-file` | | Input decryption passwords, one per line; never used for output encryption | +| `--load-db` | | Load an existing database into memory before scanning | +| `--max-file-size` | `10485760` | Skip files larger than this size in bytes (0 to disable) | +| `--only` | | Alias for --bundle-name (repeatable) | +| `--output-password-file` | | Output password for encrypted key/YAML and P12 artifacts (P12 defaults to changeit; key/YAML remain unencrypted) | +| `--password-file` | | Input decryption passwords, one per line (alias for --input-password-file) | +| `--passwords`, `-p` | | Comma-separated input decryption passwords; never used for scan output encryption | +| `--require-bundle` | | Fail unless each named bundle can be produced (repeatable) | +| `--save-db` | | Save the in-memory database to disk after scanning | +| `--trust-store` | `mozilla` | Trust store: system, mozilla | +| `--write` | `false` | Apply the bundle export plan (default is a read-only preview) | ### Keygen Flags @@ -368,24 +392,57 @@ Bundles without an explicit `subject` block inherit from `defaultSubject`. Certi ### Bundle Output Files -When running `certkit scan --bundle-path`, each bundle produces the following files under `//`. If `--duplicates` keeps older matching certificates, those extra exports are written under suffixed directories like `__/`: - -| File | Contents | -| ------------------------ | ------------------------------------------------------------------------------------- | -| `.pem` | Leaf certificate | -| `.chain.pem` | Leaf + intermediates | -| `.fullchain.pem` | Leaf + intermediates + root | -| `.intermediates.pem` | Intermediate certificates | -| `.root.pem` | Root certificate | -| `.key` | Private key (PKCS#8 PEM, mode 0600). When an export password is supplied, contains an `ENCRYPTED PRIVATE KEY` block (PKCS#8 v2, PBES2/AES-256-CBC) | -| `.p12` | PKCS#12 archive (uses first non-empty export password from `--passwords`/`--password-file`, or defaults to `changeit` with a warning, mode 0600) | -| `.k8s.yaml` | Kubernetes `kubernetes.io/tls` Secret (mode 0600) | -| `.json` | Certificate metadata | -| `.yaml` | Certificate and key metadata (mode 0600). When an export password is supplied, the `key` field contains an `ENCRYPTED PRIVATE KEY` block | -| `.csr` | Certificate Signing Request | -| `.csr.json` | CSR details (subject, SANs, key algorithm) | - -Wildcard characters in the CN are replaced with `_` in filenames (e.g., `*.example.com` becomes `_.example.com`). The `.intermediates.pem` and `.root.pem` files are only created when those certificates exist in the chain. +`certkit scan --bundle-path ` previews a plan. Add `--write` to create or replace `//`. Repeat `--bundle-name` (or `--only`) to limit the scope. Explicitly selected names must be produced; `--require-bundle` adds required names without narrowing an otherwise unscoped export, and `--fail-on-skip` makes every skip fatal. Missing, malformed, empty, or ambiguous configuration is an error during export. + +Selection is deterministic: latest `NotAfter`, then latest `NotBefore`, then lowest SHA-256 fingerprint. The plan reports the winning certificate and selection order. The newest candidate is not silently replaced by an older candidate if it lacks a key or fails trust verification. `--duplicates` additionally exports older candidates to directories suffixed with a UTC timestamp, serial, and fingerprint prefix. + +With `--duplicates`, required or explicitly selected names still require the primary `` directory. Skipped historical candidates do not fail that requirement when the primary bundle can be produced; `--fail-on-skip` makes those skips fatal too. Kubernetes Secrets in every duplicate directory retain the configured bundle name as `metadata.name`, validated before export. + +Existing bundles are protected against shorter validity, equal expiration with a different certificate, an unidentifiable existing leaf, and malformed certificate metadata. Invalid or incomplete JSON/YAML certificate artifacts block replacement and identify the filename, even when another artifact contains a valid leaf. JSON requires nonempty `pem`, YAML requires nonempty `crt`, and manifests require `bundle_name` and `leaf.pem`. Artifact extensions are inspected without case sensitivity; CSR JSON and Kubernetes YAML remain excluded from leaf inspection. `--force` explicitly overrides these replacement checks and also disables trust verification. Skipped candidates and their reasons are always shown. Blocked replacements or unmet requirements return exit code 2 and prevent all planned writes. Each directory is staged before replacement; a later write-time error can leave earlier bundles applied, and the result manifest shows their status. A lock rejects overlapping certkit refreshes against the same output directory. If a process is killed and leaves `.certkit-refresh.lock`, remove that empty directory only after confirming no refresh is still running. + +If replacement succeeds but removing its backup fails, the command reports the cleanup error and retained backup path, stops later writes, and keeps that bundle's status as `replaced`. The new bundle is already installed; the result manifest agrees with its on-disk manifest. Refresh-lock cleanup failures also return an error without hiding committed statuses. Lock ownership is checked before replacement, and cleanup uses the originally opened output directory so a moved directory or replacement lock cannot redirect removal. + +Each plan shows its absolute, resolved output directory. An in-memory plan keeps that destination if the working directory changes or the original symlink is retargeted. Replacing the resolved directory, or its nearest existing ancestor when output is new, invalidates the plan. Directory identity is checked before writing, under the refresh lock, and after the manifest is staged, immediately before replacement through the opened parent directory. A destination change, lost lock, or scope conflict at write time marks pending entries as blocked and invalidates the plan; already committed entries retain their status. Failed reinspection of an output that becomes unreadable, symlinked, or non-regular also blocks the affected entry and invalidates the plan. + +Managed root and intermediate CA bundles use the manifest's selected certificate for replacement comparisons, so chain CAs are not mistaken for the selected certificate. + +Managed exports reserve `manifest.json` for the export manifest and `.certkit-refresh.lock` for the output-directory lock, including case variants. A certificate whose generated JSON filename collides with `manifest.json` must omit the `json` format. A bundle whose directory name collides with the lock must use a different configured `bundleName`. These collisions fail during planning without writing files, even with `--force`. + +Bundle directory names must also remain distinct after sanitization, Unicode normalization, and case-insensitive comparison. For example, CN-derived names `MIXED.example.com` and `mixed.example.com` cannot be exported together, nor can composed and decomposed spellings of the same Unicode name. Every selected rule reserves its primary directory before candidates are checked, including rules without a matching certificate. Scoped refresh rejects names claimed by an unselected configuration rule, even before those directories exist. It also rejects aliases of existing directories and refuses to replace a directory whose manifest identifies a different bundle. These protections apply even with `--force`; directory names and contents are rechecked before writing. Use the exact existing bundle name or configure distinct names to preserve both outputs. + +Without an explicit `bundleName`, derived names must also be distinct within one rule: `*.example.com` and `_.example.com` both derive `_.example.com` and are rejected. An explicitly named rule can deliberately group multiple common names as candidates for the same bundle. + +Managed directory and artifact names must be portable: control characters (including NUL, tabs, and newlines), names ending in a period, and Windows device names such as `CON`, `NUL`, or `COM1` are rejected on every platform. An explicit, distinct `bundleName` fixes a directory-only collision. Artifact filenames still come from the CN, so a safe bundle name does not permit control characters or reserved filenames such as `CON.pem` or `COM1.example.com.key`; these fail during planning too. Directory components and artifact filenames have a 255-byte UTF-8 limit, including extensions. Temporary and backup directories use short names independent of the bundle name. Staging, artifact writes, renames, and cleanup use opened directory handles, so moving the output root cannot strand staged keys or redirect cleanup to replacement paths. + +Default artifacts are `pem,key,chain,fullchain,intermediates,root,json,p12`. Select any subset with `--formats`; public-only formats work without a private key. YAML, Kubernetes secrets, and CSR files require explicit selection: + +| Format | File | Contents | +| --- | --- | --- | +| `pem` | `.pem` | Leaf certificate | +| `chain` | `.chain.pem` | Leaf + intermediates | +| `fullchain` | `.fullchain.pem` | Leaf + intermediates + root | +| `intermediates` | `.intermediates.pem` | Intermediates, when present | +| `root` | `.root.pem` | Root, when present | +| `key` | `.key` | PKCS#8 private key, mode 0600 | +| `json` | `.json` | Public certificate metadata | +| `yaml` | `.yaml` | Certificate metadata **and private key**, mode 0600 | +| `p12` | `.p12` | PKCS#12 archive; password defaults to `changeit`, mode 0600 | +| `k8s` | `.k8s.yaml` | Kubernetes TLS Secret with an unencrypted private key, mode 0600 | +| `csr` | `.csr` | Certificate Signing Request | +| `csr-json` | `.csr.json` | CSR details | +| Always | `manifest.json` | Export decision, rule, provenance, leaf identity, validity, trust result, and artifact list; no private keys or passwords | + +For `scan`, `--passwords`, `--password-file`, and `--input-password-file` are input decryption credentials only. `--output-password-file` contains exactly one nonempty password and controls encryption of `.key` and `.yaml` output, plus the P12 password. Without an explicit output password, P12 retains the legacy `changeit` default with a warning on stderr, while `.key` and `.yaml` contain unencrypted keys. Omit `p12` from `--formats` to omit the archive. Kubernetes TLS secrets always contain unencrypted keys. + +Candidates whose `NotBefore` is in the future are always skipped, including with `--force` or `--allow-expired`. Expired candidates are skipped unless `--allow-expired` is supplied, even with `--force`. Required keys, validity dates, and trust are checked before replacement conflicts, so optional candidates that cannot be exported do not block other bundles. Explicitly selected or required bundles and `--fail-on-skip` still make those skips fatal. When expired leaves are allowed and verification is enabled, trust is checked at the leaf's `NotBefore` time; the manifest records that historical verification time in chain warnings. Certificates that cannot build a trusted chain still require `--force`. Unselected candidates appear in `skipped_candidates` with identity, provenance, and the tie-breaker that excluded them; these alternatives do not trigger `--fail-on-skip` when the requested bundle can be produced. + +Validity and the planned trusted chain are rechecked for all planned writes before any bundle is replaced, and again after artifacts are staged, immediately before each replacement. Existing directory names and contents are also checked at that final step, preserving edits made while other bundles or the current bundle were being staged. Reverification uses the chain that produced the planned artifacts, so an expired intermediate or root blocks a verified write even if the leaf remains valid. A candidate that expires after planning blocks the write unless `--allow-expired` was supplied; that option retains historical verification for expired leaves and records its time in the manifest. A candidate that is not yet valid at write time always blocks the write, even with that option. `--force` continues to disable trust verification. + +A preview creates no output directories, manifests, database snapshots, or key files. It can fetch AIA certificates to evaluate chains. Bundle names are assigned after AIA resolution, so fetched CA certificates can also match configured bundles. `--bundle-path` cannot be combined with `--dump-keys` or `--dump-certs`; preview mode also rejects `--save-db`. The declared output directory, dump files, database paths (`--save-db` and `--load-db`), and password files are excluded from directory ingestion, including symlink, case, and Unicode-normalization aliases. Exclusions resolve symlinked working directories and existing ancestors of not-yet-created output paths. Exclusions compare whole path components, so a sibling such as `bundles-old` remains eligible when `bundles` is excluded. `--load-db` still deliberately imports the saved inventory before scanning. Applying a plan replaces the entire managed bundle directory; the plan lists files that will be removed when artifact selection changes. Files in unselected bundle directories are untouched. + +Keep the config, password files, and database paths outside `--bundle-path`. Managed previews and writes reject these paths inside the output tree, including aliases and future database destinations, before loading inputs. This prevents refresh from deleting a control file or saving an unlisted database inside a bundle. + +Wildcard characters in the CN are replaced with `_` in filenames (e.g., `*.example.com` becomes `_.example.com`). ### Library diff --git a/bundle.go b/bundle.go index d4638c0..cbc5c21 100644 --- a/bundle.go +++ b/bundle.go @@ -517,6 +517,9 @@ type BundleOptions struct { CustomRoots []*x509.Certificate // Verify enables chain verification against the trust store. Verify bool + // AllowExpired verifies expired leaves at their NotBefore time when Verify + // is enabled. Trust and all other verification constraints still apply. + AllowExpired bool // ExcludeRoot omits the root certificate from the result. ExcludeRoot bool // AllowPrivateNetworks allows AIA fetches to private/internal endpoints. @@ -1080,6 +1083,9 @@ func Bundle(ctx context.Context, input BundleInput) (*BundleResult, error) { Intermediates: intermediatePool, Roots: rootPool, } + if opts.AllowExpired && time.Now().After(leaf.NotAfter) { + verifyOpts.CurrentTime = leaf.NotBefore + } chains, err := leaf.Verify(verifyOpts) if err != nil { if result.AIAIncomplete { @@ -1094,6 +1100,10 @@ func Bundle(ctx context.Context, input BundleInput) (*BundleResult, error) { return result, fmt.Errorf("%w: %w", ErrChainVerificationFailed, err) } + if !verifyOpts.CurrentTime.IsZero() { + result.Warnings = append(result.Warnings, "expired leaf: chain verified at "+verifyOpts.CurrentTime.UTC().Format(time.RFC3339)) + } + // Pick shortest valid chain best := chains[0] for _, chain := range chains[1:] { diff --git a/bundle_test.go b/bundle_test.go index d35fcb6..c7448a0 100644 --- a/bundle_test.go +++ b/bundle_test.go @@ -9,6 +9,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/pem" + "errors" "net" "net/http" "net/http/httptest" @@ -780,6 +781,59 @@ func TestBundle_ExpiryWarnings(t *testing.T) { } } +func TestBundle_AllowExpiredPreservesVerification(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + allowExpired bool + trusted bool + future bool + wantValid bool + }{ + {"expired leaf rejected by default", false, true, false, false}, + {"expired trusted leaf allowed", true, true, false, true}, + {"expired untrusted leaf still rejected", true, false, false, false}, + {"future leaf still rejected", true, true, true, false}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ca := generateTestCA(t, "expiry test root") + leaf := generateTestLeafCert(t, ca, func(cert *x509.Certificate) { + cert.NotBefore = time.Now().Add(-30 * time.Minute) + cert.NotAfter = time.Now().Add(-time.Minute) + if test.future { + cert.NotBefore = time.Now().Add(time.Hour) + cert.NotAfter = time.Now().Add(2 * time.Hour) + } + }) + opts := BundleOptions{TrustStore: "custom", Verify: true, AllowExpired: test.allowExpired} + if test.trusted { + opts.CustomRoots = []*x509.Certificate{ca.Cert} + } + cert, err := x509.ParseCertificate(leaf.DER) + if err != nil { + t.Fatal(err) + } + result, err := Bundle(context.Background(), BundleInput{Leaf: cert, Options: opts}) + if !test.wantValid { + if !errors.Is(err, ErrChainVerificationFailed) { + t.Fatalf("expected verification failure, got %v", err) + } + return + } + if err != nil { + t.Fatal(err) + } + if len(result.Roots) != 1 || !result.Roots[0].Equal(ca.Cert) { + t.Fatal("historical verification did not build trusted chain") + } + if !strings.Contains(strings.Join(result.Warnings, "\n"), "chain verified at ") { + t.Fatal("historical verification time was not disclosed") + } + }) + } +} + func TestFetchAIACertificates_duplicateURLs(t *testing.T) { // WHY: Duplicate AIA URLs in a cert must be deduplicated to avoid redundant HTTP fetches and duplicate certs in the chain. t.Parallel() diff --git a/cmd/certkit/readonly_commands_test.go b/cmd/certkit/readonly_commands_test.go index 808fb29..7d449be 100644 --- a/cmd/certkit/readonly_commands_test.go +++ b/cmd/certkit/readonly_commands_test.go @@ -15,6 +15,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/sensiblebit/certkit/internal" "github.com/spf13/cobra" @@ -35,6 +36,8 @@ type readonlyGlobals struct { passwordFile string // scan flags + scanAIATimeout time.Duration + scanRefresh scanRefreshFlags scanBundlePath string scanConfigPath string scanTrustStore string @@ -95,7 +98,9 @@ func snapshotReadonlyGlobals() readonlyGlobals { passwordList: passwordCopy, passwordFile: passwordFile, + scanRefresh: scanRefresh, scanBundlePath: scanBundlePath, + scanAIATimeout: scanAIATimeout, scanConfigPath: scanConfigPath, scanTrustStore: scanTrustStore, scanForceExport: scanForceExport, @@ -148,6 +153,8 @@ func restoreReadonlyGlobals(g readonlyGlobals) { passwordList = append([]string(nil), g.passwordList...) passwordFile = g.passwordFile + scanRefresh = g.scanRefresh + scanAIATimeout = g.scanAIATimeout scanBundlePath = g.scanBundlePath scanConfigPath = g.scanConfigPath scanTrustStore = g.scanTrustStore @@ -481,10 +488,10 @@ func TestRunScan_ExportEnablesSystemFallback(t *testing.T) { defer restoreReadonlyGlobals(snap) origSummaryLoader := scanSummaryTrustPoolLoader - origExportBundles := scanExportBundles + origPlanBundles := scanPlanBundles t.Cleanup(func() { scanSummaryTrustPoolLoader = origSummaryLoader - scanExportBundles = origExportBundles + scanPlanBundles = origPlanBundles }) dir := t.TempDir() @@ -498,12 +505,14 @@ func TestRunScan_ExportEnablesSystemFallback(t *testing.T) { } var gotFallback bool + var gotAllowExpired bool scanSummaryTrustPoolLoader = func(string) (scanTrustPools, error) { return scanTrustPools{}, nil } - scanExportBundles = func(_ context.Context, input internal.ExportBundlesInput) error { + scanPlanBundles = func(_ context.Context, input internal.BundlePlanInput) (*internal.BundleExportPlan, error) { gotFallback = input.AllowSystemFallback - return nil + gotAllowExpired = input.AllowExpired + return &internal.BundleExportPlan{}, nil } passwordList = nil @@ -514,6 +523,7 @@ func TestRunScan_ExportEnablesSystemFallback(t *testing.T) { scanConfigPath = configPath scanTrustStore = "mozilla" scanForceExport = false + allowExpired = true scanDuplicates = false scanDumpKeys = "" scanDumpCerts = "" @@ -523,15 +533,18 @@ func TestRunScan_ExportEnablesSystemFallback(t *testing.T) { scanSaveDB = "" scanLoadDB = "" - _, stderr, err := captureOutput(t, func() error { return runScan(newCommandWithContext(), []string{dir}) }) + stdout, _, err := captureOutput(t, func() error { return runScan(newCommandWithContext(), []string{dir}) }) if err != nil { t.Fatalf("runScan export failed: %v", err) } if !gotFallback { t.Fatal("scan export did not enable system fallback") } - if !strings.Contains(stderr, "Exported bundles to") { - t.Fatalf("scan export stderr missing export status:\n%s", stderr) + if !gotAllowExpired { + t.Fatal("scan export did not pass --allow-expired to planning") + } + if !strings.Contains(stdout, "Bundle export plan") { + t.Fatalf("scan stdout missing export plan:\n%s", stdout) } } diff --git a/cmd/certkit/root.go b/cmd/certkit/root.go index 85eb1fe..2e4d85e 100644 --- a/cmd/certkit/root.go +++ b/cmd/certkit/root.go @@ -15,9 +15,16 @@ var ( ) var rootCmd = &cobra.Command{ - Use: "certkit", - Short: "Certificate management tool", - Long: "Inspect, bundle, verify, and manage TLS/SSL certificates and keys.", + Use: "certkit", + Short: "Certificate management tool", + Long: `Inspect, bundle, verify, and manage TLS/SSL certificates and keys. + +Managed bundle refresh: scan vendor deliveries, review the plan, then apply it: + certkit scan ./tmp --config bundles.yaml --bundle-path ./bundles --bundle-name example-tls + certkit scan ./tmp --config bundles.yaml --bundle-path ./bundles --bundle-name example-tls --write + +Use scan --formats to choose artifacts and --input-password-file to decrypt inputs. +Use bundle to assemble a single certificate chain on stdout or with -o.`, SilenceUsage: true, SilenceErrors: true, PersistentPreRunE: func(_ *cobra.Command, _ []string) error { diff --git a/cmd/certkit/scan.go b/cmd/certkit/scan.go index 0e7e06a..5bd0208 100644 --- a/cmd/certkit/scan.go +++ b/cmd/certkit/scan.go @@ -42,26 +42,26 @@ var ( errScanAIARedirects = errors.New("AIA redirect limit exceeded") errScanAIAHTTPStatus = errors.New("AIA server returned non-200 status") errScanAIAResponseLarge = errors.New("AIA response exceeds size limit") - scanExportBundles = internal.ExportBundles ) var scanCmd = &cobra.Command{ Use: "scan ", - Short: "Scan and catalog certificates and keys", - Long: "Scan a file or directory for certificates, keys, and CSRs. Prints a summary of what was found. Use --bundle-path to also export bundles.", + Short: "Scan certificates and plan managed bundle refreshes", + Long: "Scan a file or directory for certificates, keys, and CSRs. With --bundle-path, show a managed bundle refresh plan. Select configured bundles with --bundle-name, review the plan, then repeat with --write to save them. Existing bundles are protected against expiration downgrades and conflicting replacements.", Example: ` certkit scan /path/to/certs certkit scan cert.pem cat cert.pem | certkit scan - - certkit scan /path/to/certs --bundle-path ./out -c bundles.yaml`, + certkit scan ./tmp --config bundles.yaml --bundle-path ./bundles --bundle-name example-tls --dry-run + certkit scan ./tmp --config bundles.yaml --bundle-path ./bundles --bundle-name example-tls --write`, Args: cobra.ExactArgs(1), RunE: runScan, } func init() { - scanCmd.Flags().StringVar(&scanBundlePath, "bundle-path", "", "Export bundles to this directory") + scanCmd.Flags().StringVar(&scanBundlePath, "bundle-path", "", "Plan bundles in this directory; --write applies the plan") scanCmd.Flags().StringVarP(&scanConfigPath, "config", "c", "./bundles.yaml", "Path to bundle config YAML") scanCmd.Flags().StringVar(&scanTrustStore, "trust-store", "mozilla", "Trust store: system, mozilla") - scanCmd.Flags().BoolVarP(&scanForceExport, "force", "f", false, "Allow export of untrusted certificate bundles") + scanCmd.Flags().BoolVarP(&scanForceExport, "force", "f", false, "Allow untrusted bundles and explicitly override replacement conflicts or expiration downgrades") scanCmd.Flags().BoolVar(&scanDuplicates, "duplicates", false, "Export all certificates per bundle, not just the newest") scanCmd.Flags().StringVar(&scanDumpKeys, "dump-keys", "", "Dump all discovered keys to a single PEM file") scanCmd.Flags().StringVar(&scanDumpCerts, "dump-certs", "", "Dump all discovered certificates to a single PEM file") @@ -79,6 +79,16 @@ func init() { func runScan(cmd *cobra.Command, args []string) error { inputPath := args[0] + if err := validateScanRefreshFlags(); err != nil { + return err + } + format := scanFormat + if jsonOutput { + format = "json" + } + if format != "text" && format != "json" { + return fmt.Errorf("%w %q (use text or json)", ErrUnsupportedOutputFormat, format) + } if scanAIATimeout <= 0 { return fmt.Errorf("%w %s: must be greater than 0", errScanAIATimeout, scanAIATimeout) } @@ -93,11 +103,10 @@ func runScan(cmd *cobra.Command, args []string) error { } } - passwordSets, err := internal.ProcessPasswordSets(passwordList, passwordFile) + passwords, outputPassword, err := scanPasswords() if err != nil { return fmt.Errorf("loading passwords: %w", err) } - passwords := passwordSets.Decode scannedFiles := 0 var lastProgressUpdate time.Time stderrInfo, err := os.Stderr.Stat() @@ -149,8 +158,7 @@ func runScan(cmd *cobra.Command, args []string) error { if scanExport { bundleConfigs, err = internal.LoadBundleConfigs(scanConfigPath) if err != nil { - slog.Warn("loading bundle configurations", "error", err) - bundleConfigs = []internal.BundleConfig{} + return fmt.Errorf("loading bundle configurations: %w", err) } } @@ -174,8 +182,9 @@ func runScan(cmd *cobra.Command, args []string) error { } err := internal.WalkScanFiles(internal.WalkScanFilesInput{ - RootPath: inputPath, - MaxFileSize: scanMaxFileSize, + RootPath: inputPath, + MaxFileSize: scanMaxFileSize, + ExcludePaths: []string{scanBundlePath, scanDumpKeys, scanDumpCerts, scanSaveDB, scanLoadDB, passwordFile, scanRefresh.InputPasswordFile, scanRefresh.OutputPasswordFile}, OnFile: func(path string) error { scannedFiles++ if archiveFormat := internal.ArchiveFormat(path); archiveFormat != "" { @@ -237,11 +246,6 @@ func runScan(cmd *cobra.Command, args []string) error { } } - // Assign bundle names post-ingestion - if scanExport { - internal.AssignBundleNames(store, bundleConfigs) - } - // Resolve missing intermediates via AIA before trust checking if certstore.HasUnresolvedIssuers(store) { slog.Debug("resolving certificate chains") @@ -255,9 +259,9 @@ func runScan(cmd *cobra.Command, args []string) error { } } - format := scanFormat - if jsonOutput { - format = "json" + // Assign names after all ingestion, including certificates fetched through AIA. + if scanExport { + internal.AssignBundleNames(store, bundleConfigs) } if scanDumpKeys != "" { @@ -395,76 +399,10 @@ func runScan(cmd *cobra.Command, args []string) error { return err } } - p12Password, usedDefault := bundleExportPassword(passwordSets.Export) - // Full export workflow — MemStore handles chain resolution via raw ASN.1 matching - //nolint:gosec // Bundle dirs need traversal bits so public bundle artifacts remain readable; sensitive files stay 0600. - if err := os.MkdirAll(scanBundlePath, 0o755); err != nil { - return fmt.Errorf("creating output directory %s: %w", scanBundlePath, err) - } - if err := scanExportBundles(cmd.Context(), internal.ExportBundlesInput{ - Configs: bundleConfigs, - OutDir: scanBundlePath, - Store: store, - TrustStore: scanTrustStore, - ForceBundle: scanForceExport, - Duplicates: scanDuplicates, - P12Password: p12Password, - AllowSystemFallback: true, - EncryptKey: len(passwordSets.Export) > 0, + if err := runScanBundleExport(cmd.Context(), runScanBundleExportInput{ + Store: store, Configs: bundleConfigs, OutputPassword: outputPassword, Format: format, }); err != nil { - return fmt.Errorf("exporting bundles: %w", err) - } - if usedDefault { - warnDefaultExportPassword() - } - store.DumpDebug() - switch format { - case "json": - trustPools, err := scanSummaryTrustPoolLoader(scanTrustStore) - if err != nil { - return err - } - summary := store.ScanSummary(certstore.ScanSummaryInput{ - MozillaPool: trustPools.Mozilla, - SystemPool: trustPools.System, - }) - output := scanExportJSON{ - ScanSummary: summary, - BundlePath: scanBundlePath, - } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("marshaling JSON: %w", err) - } - fmt.Println(string(data)) - case "text": - trustPools, err := scanSummaryTrustPoolLoader(scanTrustStore) - if err != nil { - return err - } - summary := store.ScanSummary(certstore.ScanSummaryInput{ - MozillaPool: trustPools.Mozilla, - SystemPool: trustPools.System, - }) - fmt.Print(internal.FormatScanTextSummary(internal.ScanTextSummaryInput{ - Files: scannedFiles, - Roots: summary.Roots, - Intermediates: summary.Intermediates, - Leaves: summary.Leaves, - Keys: summary.Keys, - Matched: summary.Matched, - ExpiredRoots: summary.ExpiredRoots, - ExpiredIntermediates: summary.ExpiredIntermediates, - ExpiredLeaves: summary.ExpiredLeaves, - UntrustedRoots: summary.UntrustedRoots, - UntrustedIntermediates: summary.UntrustedIntermediates, - UntrustedLeaves: summary.UntrustedLeaves, - })) - if _, err := fmt.Fprintf(os.Stderr, "Exported bundles to %s\n", scanBundlePath); err != nil { - return fmt.Errorf("writing export status: %w", err) - } - default: - return fmt.Errorf("%w %q (use text or json)", ErrUnsupportedOutputFormat, format) + return fmt.Errorf("exporting managed scan bundles: %w", err) } } else { if progressEnabled { @@ -588,7 +526,9 @@ type scanVerboseJSON struct { type scanExportJSON struct { certstore.ScanSummary - BundlePath string `json:"bundle_path"` + BundlePath string `json:"bundle_path"` + DryRun bool `json:"dry_run"` + Exports []internal.BundleExportEntry `json:"exports"` } func buildScanCertList(store *certstore.MemStore) []scanCertEntry { diff --git a/cmd/certkit/scan_aia_export_test.go b/cmd/certkit/scan_aia_export_test.go new file mode 100644 index 0000000..41f9ca4 --- /dev/null +++ b/cmd/certkit/scan_aia_export_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "crypto/rand" + "crypto/x509" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sensiblebit/certkit" +) + +func TestRunScan_ExportsConfiguredAIACertificate(t *testing.T) { + for _, write := range []bool{false, true} { + name := "preview" + if write { + name = "write" + } + t.Run(name, func(t *testing.T) { + _, input := setupScanRefreshTest(t) + issuerKey, issuer := generateKeyAndCert(t, "issuer.example.com", true) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if _, err := w.Write(issuer.Raw); err != nil { + t.Errorf("serving AIA certificate: %v", err) + } + })) + t.Cleanup(server.Close) + _, leaf := signCert(t, "service.example.com", false, issuerKey, issuer) + leaf.IssuingCertificateURL = []string{server.URL + "/issuer.der"} + der, err := x509.CreateCertificate(rand.Reader, leaf, issuer, leaf.PublicKey, issuerKey) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(input, "leaf.der"), der, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(scanConfigPath, []byte("bundles:\n - bundleName: issuer-ca\n commonNames: [issuer.example.com]\n"), 0600); err != nil { + t.Fatal(err) + } + scanAllowPrivateNetwork = true + scanRefresh.Names, scanRefresh.Formats = []string{"issuer-ca"}, []string{"pem"} + scanRefresh.Write = write + stdout, _, err := captureOutput(t, func() error { + return runScan(newCommandWithContext(), []string{input}) + }) + if err != nil { + t.Fatalf("exporting configured AIA certificate: %v", err) + } + var output scanExportJSON + if err := json.Unmarshal([]byte(stdout), &output); err != nil { + t.Fatal(err) + } + if len(output.Exports) != 1 { + t.Fatalf("exports = %d, want one configured CA", len(output.Exports)) + } + entry := output.Exports[0] + if entry.BundleName != "issuer-ca" || entry.Leaf == nil || entry.Leaf.Fingerprint != certkit.CertFingerprint(issuer) { + t.Fatalf("wrong AIA candidate: %+v", entry) + } + if !strings.Contains(entry.Leaf.Source, server.URL) { + t.Fatalf("missing AIA provenance: %s", entry.Leaf.Source) + } + if write { + if entry.Status != "created" { + t.Fatalf("status = %s, want created", entry.Status) + } + if _, err := os.Stat(filepath.Join(entry.OutputDirectory, "issuer.example.com.pem")); err != nil { + t.Fatalf("missing CA output: %v", err) + } + } else { + if entry.Status != "planned" { + t.Fatalf("status = %s, want planned", entry.Status) + } + if _, err := os.Stat(scanBundlePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("preview wrote output: %v", err) + } + } + }) + } +} diff --git a/cmd/certkit/scan_export.go b/cmd/certkit/scan_export.go new file mode 100644 index 0000000..1e92aab --- /dev/null +++ b/cmd/certkit/scan_export.go @@ -0,0 +1,224 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "slices" + "strings" + "time" + + "github.com/sensiblebit/certkit" + "github.com/sensiblebit/certkit/internal" + "github.com/sensiblebit/certkit/internal/certstore" +) + +type scanRefreshFlags struct { + Names []string + Only []string + Required []string + Formats []string + InputPasswordFile string + OutputPasswordFile string + Write bool + DryRun bool + FailOnSkip bool +} + +var errScanRefreshOptions = errors.New("invalid managed bundle options") + +var scanRefresh scanRefreshFlags +var scanPlanBundles = internal.PlanBundleExports + +func init() { + flags := scanCmd.Flags() + flags.StringSliceVar(&scanRefresh.Names, "bundle-name", nil, "Export only these configured bundle names (repeatable)") + flags.StringSliceVar(&scanRefresh.Only, "only", nil, "Alias for --bundle-name (repeatable)") + flags.StringSliceVar(&scanRefresh.Required, "require-bundle", nil, "Fail unless each named bundle can be produced (repeatable)") + flags.StringSliceVar(&scanRefresh.Formats, "formats", nil, "Bundle artifacts: pem,key,chain,fullchain,intermediates,root,json,yaml,p12,k8s,csr,csr-json (default pem,key,chain,fullchain,intermediates,root,json,p12)") + flags.StringVar(&scanRefresh.InputPasswordFile, "input-password-file", "", "Input decryption passwords, one per line; never used for output encryption") + flags.StringVar(&scanRefresh.OutputPasswordFile, "output-password-file", "", "Output password for encrypted key/YAML and P12 artifacts (P12 defaults to changeit; key/YAML remain unencrypted)") + flags.BoolVar(&scanRefresh.Write, "write", false, "Apply the bundle export plan (default is a read-only preview)") + flags.BoolVar(&scanRefresh.DryRun, "dry-run", false, "Show the export plan without writing any files") + flags.BoolVar(&scanRefresh.FailOnSkip, "fail-on-skip", false, "Fail the entire export if any requested bundle is skipped") + // These command-local flags deliberately shadow the legacy shared semantics. + flags.StringSliceVarP(&passwordList, "passwords", "p", nil, "Comma-separated input decryption passwords; never used for scan output encryption") + flags.StringVar(&passwordFile, "password-file", "", "Input decryption passwords, one per line (alias for --input-password-file)") +} + +func validateScanRefreshFlags() error { + for _, selection := range []struct { + flag string + values []string + }{{"--bundle-name", scanRefresh.Names}, {"--only", scanRefresh.Only}, {"--require-bundle", scanRefresh.Required}, {"--formats", scanRefresh.Formats}} { + if selection.values != nil && len(selection.values) == 0 { + return fmt.Errorf("%w: %s must not be empty", errScanRefreshOptions, selection.flag) + } + } + if scanRefresh.Write && scanRefresh.DryRun { + return fmt.Errorf("%w: --write and --dry-run cannot be combined", errScanRefreshOptions) + } + if scanBundlePath == "" { + if scanRefresh.Write || scanRefresh.DryRun || scanRefresh.FailOnSkip || len(scanRefresh.Names)+len(scanRefresh.Only)+len(scanRefresh.Required)+len(scanRefresh.Formats) > 0 || scanRefresh.OutputPasswordFile != "" { + return fmt.Errorf("%w: bundle export options require --bundle-path", errScanRefreshOptions) + } + return nil + } + if scanDumpKeys != "" || scanDumpCerts != "" { + return fmt.Errorf("%w: --bundle-path cannot be combined with --dump-keys or --dump-certs; select bundle artifacts with --formats", errScanRefreshOptions) + } + if !scanRefresh.Write && scanSaveDB != "" { + return fmt.Errorf("%w: a bundle preview cannot write --save-db; use --write or scan separately", errScanRefreshOptions) + } + for _, file := range []struct { + flag string + path string + }{ + {"--config", scanConfigPath}, {"--password-file", passwordFile}, + {"--input-password-file", scanRefresh.InputPasswordFile}, {"--output-password-file", scanRefresh.OutputPasswordFile}, + {"--load-db", scanLoadDB}, {"--save-db", scanSaveDB}, + } { + if err := internal.ValidateBundleInputPath(scanBundlePath, file.path); err != nil { + return fmt.Errorf("%w: %s: %w", errScanRefreshOptions, file.flag, err) + } + } + return nil +} + +func scanPasswords() ([]string, string, error) { + passwords := certkit.DeduplicatePasswords(passwordList) + for _, path := range []string{passwordFile, scanRefresh.InputPasswordFile} { + if path == "" { + continue + } + data, err := internal.ReadFileLimited(path, 64*1024) + if err != nil { + return nil, "", fmt.Errorf("reading input password file: %w", err) + } + for line := range strings.SplitSeq(string(data), "\n") { + password := strings.TrimSuffix(line, "\r") + if password != "" { + passwords = append(passwords, password) + } + } + } + passwords = certkit.DeduplicatePasswords(passwords) + outputPassword := "" + if scanRefresh.OutputPasswordFile != "" { + data, err := internal.ReadFileLimited(scanRefresh.OutputPasswordFile, 64*1024) + if err != nil { + return nil, "", fmt.Errorf("reading output password file: %w", err) + } + outputPassword = strings.TrimSuffix(strings.TrimSuffix(string(data), "\n"), "\r") + if strings.TrimSpace(outputPassword) == "" || strings.ContainsAny(outputPassword, "\r\n") { + return nil, "", fmt.Errorf("%w: output password file must contain exactly one nonempty password", errScanRefreshOptions) + } + } + return passwords, outputPassword, nil +} + +type runScanBundleExportInput struct { + Store *certstore.MemStore + Configs []internal.BundleConfig + OutputPassword string + Format string +} + +func runScanBundleExport(ctx context.Context, input runScanBundleExportInput) error { + plan, err := scanPlanBundles(ctx, internal.BundlePlanInput{ + Configs: input.Configs, OutDir: scanBundlePath, + Store: input.Store, TrustStore: scanTrustStore, ForceBundle: scanForceExport, + Duplicates: scanDuplicates, P12Password: input.OutputPassword, + AllowSystemFallback: true, EncryptKey: input.OutputPassword != "", + ConfigPath: scanConfigPath, BundleNames: slices.Concat(scanRefresh.Names, scanRefresh.Only), + RequireBundles: scanRefresh.Required, FailOnSkip: scanRefresh.FailOnSkip, + Formats: scanRefresh.Formats, AllowPrivateNetworks: scanAllowPrivateNetwork, AIATimeout: scanAIATimeout, + AllowExpired: allowExpired, + }) + if err != nil { + return fmt.Errorf("planning bundle exports: %w", err) + } + if input.OutputPassword == "" && slices.ContainsFunc(plan.Entries, func(entry internal.BundleExportEntry) bool { + return slices.Contains(entry.Formats, "p12") + }) { + if _, err := fmt.Fprintln(os.Stderr, "Using default password 'changeit'. Use --output-password-file for scan exports."); err != nil { + return fmt.Errorf("writing default-password warning: %w", err) + } + } + trustPools, err := scanSummaryTrustPoolLoader(scanTrustStore) + if err != nil { + return fmt.Errorf("loading scan trust pools: %w", err) + } + planErr := plan.Validate() + if scanRefresh.Write && planErr == nil { + planErr = plan.Write(ctx) + } + summary := input.Store.ScanSummary(certstore.ScanSummaryInput{MozillaPool: trustPools.Mozilla, SystemPool: trustPools.System}) + if input.Format == "json" { + output := scanExportJSON{ScanSummary: summary, BundlePath: scanBundlePath, + DryRun: !scanRefresh.Write, Exports: plan.Entries} + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("encoding bundle export plan: %w", err) + } + if _, err := fmt.Fprintln(os.Stdout, string(data)); err != nil { + return fmt.Errorf("writing bundle export plan: %w", err) + } + } else { + if err := printBundleExportPlan(plan); err != nil { + return fmt.Errorf("printing bundle export plan: %w", err) + } + } + if planErr != nil { + if errors.Is(planErr, internal.ErrBundlePlanBlocked) { + return &ValidationError{Message: planErr.Error()} + } + return fmt.Errorf("applying bundle export plan: %w", planErr) + } + return nil +} + +func printBundleExportPlan(plan *internal.BundleExportPlan) error { + var out strings.Builder + if scanRefresh.Write { + out.WriteString("Bundle export results\n") + } else { + out.WriteString("Bundle export plan (no files written; use --write to apply)\n") + } + for _, entry := range plan.Entries { + fmt.Fprintf(&out, "\n%s: %s (%s)\n Directory: %s\n Reason: %s\n", entry.BundleName, entry.Status, entry.Action, entry.OutputDirectory, entry.Reason) + if entry.Rule != nil { + fmt.Fprintf(&out, " Rule: %s #%d (%s)\n", entry.Rule.ConfigPath, entry.Rule.Index, strings.Join(entry.Rule.CommonNames, ", ")) + } + for _, skipped := range entry.SkippedCandidates { + fmt.Fprintf(&out, " Unselected candidate: serial=%s SHA-256=%s\n Validity: %s to %s\n Source: %s\n Key source: %s\n Reason: %s\n", + skipped.Leaf.Serial, skipped.Leaf.Fingerprint, skipped.Leaf.NotBefore.Format(time.RFC3339), + skipped.Leaf.NotAfter.Format(time.RFC3339), skipped.Leaf.Source, skipped.KeySource, skipped.Reason) + } + for _, value := range []struct { + label string + leaf *internal.BundleLeaf + }{{"Candidate", entry.Leaf}, {"Existing", entry.ExistingLeaf}} { + if value.leaf != nil { + leaf := value.leaf + fmt.Fprintf(&out, " %s: CN=%s serial=%s\n SHA-256: %s\n Validity: %s to %s\n Source: %s\n", value.label, leaf.CommonName, leaf.Serial, leaf.Fingerprint, leaf.NotBefore.Format(time.RFC3339), leaf.NotAfter.Format(time.RFC3339), leaf.Source) + } + } + if entry.Leaf != nil { + fmt.Fprintf(&out, " Selection: %s (%d candidates)\n Key source: %s\n Chain: %s (%s)\n", entry.SelectionReason, entry.CandidateCount, entry.KeySource, entry.Chain.Status, entry.Chain.TrustStore) + } + for _, warning := range entry.Chain.Warnings { + fmt.Fprintf(&out, " Chain warning: %s\n", warning) + } + fmt.Fprintf(&out, " Files: %s\n", strings.Join(entry.Files, ", ")) + if len(entry.RemovedFiles) > 0 { + fmt.Fprintf(&out, " Removed on replacement: %s\n", strings.Join(entry.RemovedFiles, ", ")) + } + } + if _, err := fmt.Fprint(os.Stdout, out.String()); err != nil { + return fmt.Errorf("writing bundle export plan: %w", err) + } + return nil +} diff --git a/cmd/certkit/scan_export_test.go b/cmd/certkit/scan_export_test.go new file mode 100644 index 0000000..1676ddd --- /dev/null +++ b/cmd/certkit/scan_export_test.go @@ -0,0 +1,241 @@ +package main + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sensiblebit/certkit" + "github.com/sensiblebit/certkit/internal" +) + +func setupScanRefreshTest(t *testing.T) (string, string) { + t.Helper() + snapshot := snapshotReadonlyGlobals() + t.Cleanup(func() { restoreReadonlyGlobals(snapshot) }) + scanRefresh = scanRefreshFlags{} + passwordList, passwordFile = nil, "" + jsonOutput, verbose, allowExpired = true, false, false + scanForceExport, scanDuplicates = true, false + scanDumpKeys, scanDumpCerts, scanSaveDB, scanLoadDB = "", "", "", "" + scanMaxFileSize, scanFormat, scanTrustStore = 10*1024*1024, "json", "mozilla" + scanAIATimeout, scanAllowPrivateNetwork = time.Second, false + dir := t.TempDir() + input := filepath.Join(dir, "vendor") + if err := os.Mkdir(input, 0700); err != nil { + t.Fatal(err) + } + scanBundlePath = filepath.Join(dir, "bundles") + scanConfigPath = filepath.Join(dir, "bundles.yaml") + if err := os.WriteFile(scanConfigPath, []byte("bundles:\n - bundleName: service-tls\n commonNames: [service.example.com]\n"), 0600); err != nil { + t.Fatal(err) + } + return dir, input +} + +func TestRunScan_ManagedBundleWorkflow(t *testing.T) { + for _, test := range []struct { + name string + write bool + dryRun bool + legacyPassword bool + outputPassword bool + archive bool + omitP12 bool + }{ + {"preview by default", false, false, false, false, false, false}, + {"explicit dry run", false, true, false, false, false, false}, + {"vendor password is input only", true, false, false, false, false, false}, + {"legacy password is input only", true, false, true, false, false, false}, + {"separate output encryption", true, false, false, true, true, false}, + {"explicit formats omit P12", true, false, false, false, false, true}, + } { + t.Run(test.name, func(t *testing.T) { + dir, input := setupScanRefreshTest(t) + key, leaf := generateKeyAndCert(t, "service.example.com", false) + vendorPassword := " temporary-vendor-secret " + p12, err := certkit.EncodePKCS12Legacy(key, leaf, nil, vendorPassword) + if err != nil { + t.Fatal(err) + } + name := "delivery.p12" + if test.archive { + var data bytes.Buffer + writer := zip.NewWriter(&data) + file, err := writer.Create("vendor/leaf.p12") + if err != nil { + t.Fatal(err) + } + if _, err := file.Write(p12); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + p12, name = data.Bytes(), "delivery.zip" + } + if err := os.WriteFile(filepath.Join(input, name), p12, 0600); err != nil { + t.Fatal(err) + } + if test.legacyPassword { + passwordList = []string{vendorPassword} + } else { + scanRefresh.InputPasswordFile = filepath.Join(input, "vendor-password") + if err := os.WriteFile(scanRefresh.InputPasswordFile, []byte(vendorPassword+"\n"), 0600); err != nil { + t.Fatal(err) + } + } + scanRefresh.Write, scanRefresh.DryRun = test.write, test.dryRun + scanRefresh.Names = []string{"service-tls"} + if test.omitP12 { + scanRefresh.Formats = []string{"key", "json"} + } + if test.outputPassword { + scanRefresh.OutputPasswordFile = filepath.Join(dir, "output-password") + if err := os.WriteFile(scanRefresh.OutputPasswordFile, []byte(" deployment-secret \n"), 0600); err != nil { + t.Fatal(err) + } + scanRefresh.Formats = []string{"key", "p12", "json"} + } + stdout, stderr, err := captureOutput(t, func() error { + // Password warnings must remain visible even with --log-level error. + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + return runScan(newCommandWithContext(), []string{input}) + }) + if err != nil { + t.Fatalf("runScan: %v\nstderr: %s", err, stderr) + } + var output scanExportJSON + if err := json.Unmarshal([]byte(stdout), &output); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, stdout) + } + if output.DryRun == test.write { + t.Fatalf("dry_run = %v", output.DryRun) + } + if len(output.Exports) != 1 { + t.Fatalf("exports = %d", len(output.Exports)) + } + entry := output.Exports[0] + if entry.Leaf.Fingerprint != certkit.CertFingerprint(leaf) { + t.Fatal("wrong selected leaf") + } + if !strings.Contains(entry.Leaf.Source, name) || !strings.Contains(entry.KeySource, name) { + t.Fatalf("missing source provenance: %+v", entry) + } + if strings.Contains(stdout+stderr, vendorPassword) || strings.Contains(stdout+stderr, "deployment-secret") { + t.Fatal("password leaked into output") + } + wantDefaultWarning := !test.outputPassword && !test.omitP12 + if strings.Contains(stderr, "Using default password 'changeit'") != wantDefaultWarning { + t.Fatalf("incorrect default-password warning: %s", stderr) + } + if !test.write { + if _, err := os.Stat(scanBundlePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("preview created output: %v", err) + } + return + } + keyPath := filepath.Join(entry.OutputDirectory, "service.example.com.key") + //nolint:gosec // The key path is created by this test inside t.TempDir. + keyData, err := os.ReadFile(keyPath) + if err != nil { + t.Fatal(err) + } + if test.outputPassword { + if !bytes.Contains(keyData, []byte("ENCRYPTED PRIVATE KEY")) { + t.Fatal("explicit output password did not encrypt key") + } + if _, err := certkit.ParsePEMPrivateKeyWithPasswords(keyData, []string{" deployment-secret "}); err != nil { + t.Fatalf("output key uses wrong password: %v", err) + } + } else { + if _, err := certkit.ParsePEMPrivateKey(keyData); err != nil { + t.Fatalf("input password encrypted output key: %v", err) + } + } + p12Path := filepath.Join(entry.OutputDirectory, "service.example.com.p12") + if test.omitP12 { + if _, err := os.Stat(p12Path); !errors.Is(err, os.ErrNotExist) { + t.Fatal("unselected P12 output was generated") + } + return + } + //nolint:gosec // The export path is created by this test inside t.TempDir. + p12Data, err := os.ReadFile(p12Path) + if err != nil { + t.Fatal(err) + } + wantPassword := internal.DefaultExportPassword + if test.outputPassword { + wantPassword = " deployment-secret " + } + outputKey, outputLeaf, _, err := certkit.DecodePKCS12(p12Data, wantPassword) + if err != nil { + t.Fatalf("P12 uses wrong password: %v", err) + } + if !outputLeaf.Equal(leaf) { + t.Fatal("P12 contains wrong certificate") + } + if matches, err := certkit.KeyMatchesCert(outputKey, leaf); err != nil || !matches { + t.Fatalf("P12 contains wrong key: %v", err) + } + }) + } +} + +func TestRunScan_RejectsUnsafeExportOptions(t *testing.T) { + for _, test := range []struct { + name string + configure func() + }{ + {"empty scope", func() { scanRefresh.Names = []string{} }}, + {"empty scope alias", func() { scanRefresh.Only = []string{} }}, + {"empty required name", func() { scanRefresh.Required = []string{} }}, + {"write and dry run", func() { scanRefresh.Write, scanRefresh.DryRun = true, true }}, + {"preview with database write", func() { scanSaveDB = filepath.Join(scanBundlePath, "snapshot.db") }}, + {"preview with key dump", func() { scanDumpKeys = filepath.Join(scanBundlePath, "keys.pem") }}, + {"missing config", func() { scanConfigPath += ".missing" }}, + {"write without bundle path", func() { scanBundlePath = ""; scanRefresh.Write = true }}, + {"invalid output format", func() { jsonOutput = false; scanFormat = "invalid"; scanRefresh.Write = true }}, + } { + t.Run(test.name, func(t *testing.T) { + _, input := setupScanRefreshTest(t) + outDir := scanBundlePath + test.configure() + _, _, err := captureOutput(t, func() error { return runScan(newCommandWithContext(), []string{input}) }) + if err == nil { + t.Fatal("unsafe or invalid command accepted") + } + if _, err := os.Stat(outDir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("invalid command wrote output") + } + }) + } +} + +func TestRunScan_MissingRequiredBundleReportsFailure(t *testing.T) { + _, input := setupScanRefreshTest(t) + scanRefresh.Write = true + scanRefresh.Required = []string{"service-tls"} + stdout, _, err := captureOutput(t, func() error { return runScan(newCommandWithContext(), []string{input}) }) + if _, ok := errors.AsType[*ValidationError](err); !ok { + t.Fatalf("error = %v, want validation failure", err) + } + var output scanExportJSON + if err := json.Unmarshal([]byte(stdout), &output); err != nil { + t.Fatal(err) + } + if len(output.Exports) != 1 || output.Exports[0].Status != "skipped" || output.Exports[0].Reason == "" { + t.Fatalf("missing manifest reason: %+v", output) + } + if _, err := os.Stat(scanBundlePath); !errors.Is(err, os.ErrNotExist) { + t.Fatal("failed requirement wrote output") + } +} diff --git a/cmd/certkit/scan_feedback_test.go b/cmd/certkit/scan_feedback_test.go new file mode 100644 index 0000000..d45bd71 --- /dev/null +++ b/cmd/certkit/scan_feedback_test.go @@ -0,0 +1,244 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sensiblebit/certkit" + "github.com/sensiblebit/certkit/internal" + "github.com/sensiblebit/certkit/internal/certstore" +) + +func TestRunScan_ExcludesExistingDumpOutputs(t *testing.T) { + for _, test := range []struct { + name string + keys bool + }{ + {"key dump", true}, + {"certificate dump", false}, + } { + t.Run(test.name, func(t *testing.T) { + _, input := setupScanRefreshTest(t) + scanBundlePath = "" + key, leaf := generateKeyAndCert(t, "current.example.com", false) + delivery, err := certkit.EncodePKCS12Legacy(key, leaf, nil, internal.DefaultExportPassword) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(input, "delivery.p12"), delivery, 0600); err != nil { + t.Fatal(err) + } + oldKey, oldLeaf := generateKeyAndCert(t, "stale.example.com", false) + path := filepath.Join(input, "dump.pem") + stale := certkit.CertToPEM(oldLeaf) + if test.keys { + scanDumpKeys = path + stale, err = certkit.MarshalPrivateKeyToPEM(oldKey) + if err != nil { + t.Fatal(err) + } + } else { + scanDumpCerts = path + } + if err := os.WriteFile(path, []byte(stale), 0600); err != nil { + t.Fatal(err) + } + stdout, _, err := captureOutput(t, func() error { return runScan(newCommandWithContext(), []string{input}) }) + if err != nil { + t.Fatal(err) + } + var summary certstore.ScanSummary + if err := json.Unmarshal([]byte(stdout), &summary); err != nil { + t.Fatal(err) + } + if summary.Leaves != 1 || summary.Keys != 1 { + t.Fatalf("stale output was scanned: %+v", summary) + } + //nolint:gosec // This output path belongs to the test's temporary directory. + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if test.keys { + keys, err := certkit.ParsePEMPrivateKeys(data, nil) + if err != nil || len(keys) != 1 { + t.Fatalf("dump must contain only the current key: %v", err) + } + if matches, err := certkit.KeyMatchesCert(keys[0], leaf); err != nil || !matches { + t.Fatalf("dump contains the wrong key: %v", err) + } + } else { + certs, err := certkit.ParsePEMCertificates(data) + if err != nil || len(certs) != 1 || !certs[0].Equal(leaf) { + t.Fatalf("dump must contain only the current certificate: %v", err) + } + } + }) + } +} + +func TestRunScan_ExcludesDeclaredDatabasesFromFileIngestion(t *testing.T) { + for _, test := range []struct { + name string + load bool + save bool + }{ + {"save snapshot", false, true}, + {"load snapshot", true, false}, + {"load and save snapshot", true, true}, + } { + t.Run(test.name, func(t *testing.T) { + _, input := setupScanRefreshTest(t) + scanBundlePath, scanFormat, jsonOutput = "", "text", false + key, leaf := generateKeyAndCert(t, "current.example.com", false) + delivery, err := certkit.EncodePKCS12Legacy(key, leaf, nil, internal.DefaultExportPassword) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(input, "delivery.p12"), delivery, 0600); err != nil { + t.Fatal(err) + } + oldKey, oldLeaf := generateKeyAndCert(t, "stale.example.com", false) + oldPEM, err := certkit.MarshalPrivateKeyToPEM(oldKey) + if err != nil { + t.Fatal(err) + } + store := certstore.NewMemStore() + if err := store.HandleCertificate(oldLeaf, "old.pem"); err != nil { + t.Fatal(err) + } + if err := store.HandleKey(oldKey, []byte(oldPEM), "old.key"); err != nil { + t.Fatal(err) + } + path := filepath.Join(input, "snapshot.db") + if err := certstore.SaveToSQLite(store, path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(path, filepath.Join(input, "snapshot-alias")); err != nil { + t.Fatal(err) + } + wantCount := 1 + if test.load { + scanLoadDB = path + wantCount++ // --load-db deliberately imports the stored inventory. + } + if test.save { + scanSaveDB = path + } + stdout, _, err := captureOutput(t, func() error { return runScan(newCommandWithContext(), []string{input}) }) + if err != nil { + t.Fatal(err) + } + want := fmt.Sprintf("Found %d certificate(s) and %d key(s) in 1 file(s)\n", wantCount, wantCount) + if !strings.HasPrefix(stdout, want) { + t.Fatalf("database or its alias was ingested as a file: %s", stdout) + } + }) + } +} + +func TestRunScan_ProtectsControlFilesFromBundleReplacement(t *testing.T) { + for _, test := range []struct { + flag string + set func(string) + }{ + {"--config", func(path string) { scanConfigPath = path }}, + {"--password-file", func(path string) { passwordFile = path }}, + {"--input-password-file", func(path string) { scanRefresh.InputPasswordFile = path }}, + {"--output-password-file", func(path string) { scanRefresh.OutputPasswordFile = path }}, + {"--load-db", func(path string) { scanLoadDB = path }}, + {"--save-db", func(path string) { scanSaveDB = path }}, + } { + t.Run(test.flag, func(t *testing.T) { + _, input := setupScanRefreshTest(t) + scanRefresh.Write = true + dir := filepath.Join(scanBundlePath, "service-tls") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "control-file") + const original = "must survive replacement" + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + test.set(path) + _, _, err := captureOutput(t, func() error { return runScan(newCommandWithContext(), []string{input}) }) + if !errors.Is(err, errScanRefreshOptions) || !strings.Contains(err.Error(), test.flag) { + t.Fatalf("managed control file did not fail flag preflight: %v", err) + } + //nolint:gosec // This path belongs to the test's temporary directory. + data, err := os.ReadFile(path) + if err != nil || string(data) != original { + t.Fatalf("declared control file changed: %v", err) + } + files, err := os.ReadDir(dir) + if err != nil || len(files) != 1 { + t.Fatalf("invalid request wrote bundle artifacts: %v", err) + } + }) + } +} + +func TestRunScan_PasswordWarningFailurePreventsWrite(t *testing.T) { + dir, input := setupScanRefreshTest(t) + key, leaf := generateKeyAndCert(t, "service.example.com", false) + delivery, err := certkit.EncodePKCS12Legacy(key, leaf, nil, internal.DefaultExportPassword) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(input, "delivery.p12"), delivery, 0600); err != nil { + t.Fatal(err) + } + closed, err := os.CreateTemp(dir, "closed-stderr") + if err != nil { + t.Fatal(err) + } + if err := closed.Close(); err != nil { + t.Fatal(err) + } + scanRefresh.Write = true + _, _, err = captureOutput(t, func() error { + stderr := os.Stderr + os.Stderr = closed + defer func() { os.Stderr = stderr }() + return runScan(newCommandWithContext(), []string{input}) + }) + if !errors.Is(err, os.ErrClosed) || !strings.Contains(err.Error(), "writing default-password warning") { + t.Fatalf("warning write error was not preserved: %v", err) + } + if _, err := os.Stat(scanBundlePath); !errors.Is(err, os.ErrNotExist) { + t.Fatal("failed warning still applied the bundle plan") + } +} + +func TestRunScan_ExportOutputFailurePreservesContext(t *testing.T) { + dir, input := setupScanRefreshTest(t) + jsonOutput, scanFormat = false, "text" + scanRefresh.Formats = []string{"pem"} + closed, err := os.CreateTemp(dir, "closed-stdout") + if err != nil { + t.Fatal(err) + } + if err := closed.Close(); err != nil { + t.Fatal(err) + } + _, _, err = captureOutput(t, func() error { + stdout := os.Stdout + os.Stdout = closed + defer func() { os.Stdout = stdout }() + return runScan(newCommandWithContext(), []string{input}) + }) + if !errors.Is(err, os.ErrClosed) { + t.Fatalf("output error cause was lost: %v", err) + } + for _, context := range []string{"exporting managed scan bundles", "printing bundle export plan", "writing bundle export plan"} { + if !strings.Contains(err.Error(), context) { + t.Fatalf("missing error context %q: %v", context, err) + } + } +} diff --git a/go.mod b/go.mod index 255b12c..d1105f6 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/spf13/pflag v1.0.10 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 + golang.org/x/text v0.42.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.59.0 software.sslmate.com/src/go-pkcs12 v0.7.3 @@ -34,7 +35,7 @@ require ( google.golang.org/protobuf v1.36.12 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect k8s.io/klog/v2 v2.140.0 // indirect - modernc.org/libc v1.77.0 // indirect + modernc.org/libc v1.77.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect ) diff --git a/go.sum b/go.sum index 10b5411..caffa35 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,8 @@ golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= @@ -89,8 +91,8 @@ k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.36.0 h1:T5h7ZMcrw2r94oDLtBcU3YI6e1W8DiUg2SJIs2fwD2s= -modernc.org/ccgo/v4 v4.36.0/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA= +modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= +modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= @@ -99,8 +101,8 @@ modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.77.0 h1:wV+faa9ixRoUNlakU0fHLi/1kV2YLG8D8Ksk3c2mPtY= -modernc.org/libc v1.77.0/go.mod h1:BmXDCJcMkRMRbX2EI8+Zhh4fKWlHNbCCZEy0tiZevP4= +modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= +modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= diff --git a/internal/bundlepaths.go b/internal/bundlepaths.go new file mode 100644 index 0000000..1036880 --- /dev/null +++ b/internal/bundlepaths.go @@ -0,0 +1,225 @@ +package internal + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/text/unicode/norm" +) + +// ValidateBundleInputPath rejects files in the managed output tree, including +// symlink aliases and destinations that do not exist yet. Refresh replaces +// managed directories wholesale, so control files must be kept outside them. +func ValidateBundleInputPath(outDir, path string) error { + if path == "" { + return nil + } + output, err := filepath.Abs(outDir) + if err != nil { + return fmt.Errorf("resolving bundle output path: %w", err) + } + absolute, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("resolving bundle control path: %w", err) + } + if bundlePathWithinBoundary(absolute, output) { + return fmt.Errorf("%w: %q must be outside managed bundle output %q", errBundlePlanInput, path, outDir) + } + output, err = resolveBundleControlPath(outDir) + if err != nil { + return fmt.Errorf("resolving bundle output target: %w", err) + } + resolved, err := resolveBundleControlPath(path) + if err != nil { + return fmt.Errorf("resolving bundle control file: %w", err) + } + if bundlePathWithinBoundary(resolved, output) { + return fmt.Errorf("%w: %q resolves inside managed bundle output %q", errBundlePlanInput, path, outDir) + } + outputInfo, err := os.Stat(output) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("checking bundle output target: %w", err) + } + // Directory identity also catches case aliases on case-insensitive volumes. + for current := resolved; ; current = filepath.Dir(current) { + info, err := os.Stat(current) + if err == nil && os.SameFile(info, outputInfo) { + return fmt.Errorf("%w: %q aliases managed bundle output %q", errBundlePlanInput, path, outDir) + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("checking bundle control file ancestor: %w", err) + } + if filepath.Dir(current) == current { + return nil + } + } +} + +type bundleDestination struct { + path string + ancestor string + identity os.FileInfo +} + +// planBundleDestination pins a canonical destination and its nearest existing +// directory so changes to the working directory or path aliases cannot redirect it. +func planBundleDestination(path string) (bundleDestination, error) { + resolved, err := resolveBundleControlPath(path) + if err != nil { + return bundleDestination{}, fmt.Errorf("resolving planned bundle destination: %w", err) + } + for ancestor := resolved; ; ancestor = filepath.Dir(ancestor) { + info, err := os.Stat(ancestor) + if err == nil { + if !info.IsDir() { + return bundleDestination{}, fmt.Errorf("%w: bundle output ancestor %q must be a directory", errBundlePlanInput, ancestor) + } + return bundleDestination{path: resolved, ancestor: ancestor, identity: info}, nil + } + if !errors.Is(err, os.ErrNotExist) || filepath.Dir(ancestor) == ancestor { + return bundleDestination{}, fmt.Errorf("inspecting planned bundle destination: %w", err) + } + } +} + +func (d bundleDestination) check() error { + resolved, err := resolveBundleControlPath(d.path) + if err != nil { + return fmt.Errorf("%w: resolving planned output destination: %w", ErrBundlePlanBlocked, err) + } + if resolved != d.path { + return fmt.Errorf("%w: bundle output destination changed after planning; rerun the command", ErrBundlePlanBlocked) + } + info, err := os.Stat(d.ancestor) + if err != nil { + return fmt.Errorf("%w: checking planned output directory %q: %w", ErrBundlePlanBlocked, d.ancestor, err) + } + if !os.SameFile(info, d.identity) { + return fmt.Errorf("%w: bundle output directory identity changed after planning; rerun the command", ErrBundlePlanBlocked) + } + return nil +} + +// checkRoot binds the writer's opened parent to the pinned output identity. +// Path validation is also required: an open handle follows a renamed directory. +func (d bundleDestination) checkRoot(root *os.Root) error { + if err := d.check(); err != nil { + return err + } + info, err := root.Stat(".") + if err != nil { + return fmt.Errorf("checking opened bundle output directory: %w", err) + } + if !os.SameFile(info, d.identity) { + return fmt.Errorf("%w: opened bundle output directory differs from the reviewed destination", ErrBundlePlanBlocked) + } + return nil +} + +// pinCreatedRoot upgrades an absent output's ancestor guard after MkdirAll. +func (d *bundleDestination) pinCreatedRoot() error { + if err := d.check(); err != nil { + return err + } + if d.ancestor == d.path { + return nil + } + info, err := os.Lstat(d.path) + if err != nil { + return fmt.Errorf("checking created bundle output: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("%w: created bundle output must be a directory", ErrBundlePlanBlocked) + } + if err := d.check(); err != nil { + return err + } + d.ancestor, d.identity = d.path, info + return nil +} + +// Compare components without case sensitivity even before paths exist, matching +// the portable namespace used for managed bundle directory names. +func bundlePathWithinBoundary(path, boundary string) bool { + separator := string(os.PathSeparator) + parts := strings.Split(filepath.Clean(path), separator) + root := strings.Split(strings.TrimRight(filepath.Clean(boundary), separator), separator) + if len(parts) < len(root) { + return false + } + for i, part := range root { + if !equalBundlePathNames(parts[i], part) { + return false + } + } + return true +} + +func equalBundlePathNames(a, b string) bool { + return strings.EqualFold(norm.NFC.String(a), norm.NFC.String(b)) +} + +// resolveBundleControlPath resolves symlinks without requiring the final path +// to exist, so future database outputs receive the same protection as inputs. +func resolveBundleControlPath(path string) (string, error) { + // Do not clean .. before resolving symlinks: link/../file follows the + // symlink's target parent, which may differ from the lexical parent. + current := filepath.FromSlash(path) + var suffix []string + for { + resolved, err := filepath.EvalSymlinks(current) + if err == nil { + absolute, err := filepath.Abs(resolved) + if err != nil { + return "", fmt.Errorf("resolving absolute control target: %w", err) + } + canonical, err := filepath.EvalSymlinks(absolute) + if err != nil { + return "", fmt.Errorf("resolving absolute target symlinks: %w", err) + } + return filepath.Join(append([]string{canonical}, suffix...)...), nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("resolving control path symlinks: %w", err) + } + probe := strings.TrimRight(current, string(os.PathSeparator)) + if probe == filepath.VolumeName(current) { + probe = current + } + info, statErr := os.Lstat(probe) + if statErr == nil && info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(probe) + if err != nil { + return "", fmt.Errorf("reading control path symlink: %w", err) + } + if !filepath.IsAbs(target) { + dir, _ := filepath.Split(probe) + target = dir + target + } + current = target + } else { + if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return "", fmt.Errorf("checking control path: %w", statErr) + } + trimmed := strings.TrimRight(current, string(os.PathSeparator)) + if trimmed == filepath.VolumeName(current) { + return "", fmt.Errorf("resolving control path ancestor: %w", err) + } + parent, name := filepath.Split(trimmed) + if name == "." || name == ".." { + return "", fmt.Errorf("resolving missing control path before a dot component: %w", err) + } + if parent == "" { + parent = "." + } + suffix = append([]string{name}, suffix...) + current = parent + } + } +} diff --git a/internal/bundlepaths_test.go b/internal/bundlepaths_test.go new file mode 100644 index 0000000..e85b196 --- /dev/null +++ b/internal/bundlepaths_test.go @@ -0,0 +1,100 @@ +package internal + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestValidateBundleInputPath(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + output string + input string + link string + target string + blocked bool + }{ + {"sibling", "bundles", "bundles.yaml", "", "", false}, + {"prefix sibling", "bundles", "bundles-old/password", "", "", false}, + {"equal", "bundles", "bundles", "", "", true}, + {"future database", "bundles", "bundles/service-tls/new/db.sqlite", "", "", true}, + {"case alias before creation", "bundles", "BUNDLES/service-tls/db.sqlite", "", "", true}, + {"unicode alias before creation", "caf\u00e9", "cafe\u0301/service-tls/db.sqlite", "", "", true}, + {"output directory alias", "alias", "bundles/service-tls/password", "alias", "bundles", true}, + {"input parent alias", "bundles", "alias/service-tls/password", "alias", "bundles", true}, + {"dangling input alias", "bundles", "password", "password", "bundles/service-tls/password", true}, + {"inside link to outside", "bundles", "bundles/password", "bundles/password", "../password", true}, + {"outside alias", "bundles", "alias/db.sqlite", "alias", "inventory", false}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + if test.link != "" { + link := filepath.Join(root, test.link) + if err := os.MkdirAll(filepath.Dir(link), 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(test.target, link); err != nil { + t.Fatal(err) + } + } + err := ValidateBundleInputPath(filepath.Join(root, test.output), filepath.Join(root, test.input)) + if test.blocked && !errors.Is(err, errBundlePlanInput) { + t.Fatalf("managed control path was accepted: %v", err) + } + if !test.blocked && err != nil { + t.Fatalf("outside control path was rejected: %v", err) + } + }) + } +} + +func TestValidateBundleInputPath_ParentTraversalAfterSymlink(t *testing.T) { + t.Parallel() + for _, existing := range []bool{false, true} { + name := "future destination" + if existing { + name = "existing file" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + output := filepath.Join(root, "bundles") + if err := os.MkdirAll(filepath.Join(output, "service-tls"), 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(output, "service-tls"), filepath.Join(root, "alias")); err != nil { + t.Fatal(err) + } + if existing { + if err := os.WriteFile(filepath.Join(output, "control"), []byte("preserve"), 0600); err != nil { + t.Fatal(err) + } + } + // Preserve .. until after symlink resolution, as the file loader does. + path := filepath.Join(root, "alias") + string(os.PathSeparator) + ".." + string(os.PathSeparator) + "control" + if err := ValidateBundleInputPath(output, path); !errors.Is(err, errBundlePlanInput) { + t.Fatalf("symlink parent traversal bypassed output protection: %v", err) + } + }) + } +} + +func TestValidateBundleInputPath_OutputParentTraversalAfterSymlink(t *testing.T) { + t.Parallel() + root := t.TempDir() + actual := filepath.Join(root, "actual") + if err := os.MkdirAll(filepath.Join(actual, "child"), 0700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "alias") + createSymlinkOrSkip(t, filepath.Join(actual, "child"), alias) + output := alias + string(os.PathSeparator) + ".." + string(os.PathSeparator) + "bundles" + input := filepath.Join(actual, "bundles", "password") + if err := ValidateBundleInputPath(output, input); !errors.Is(err, errBundlePlanInput) { + t.Fatalf("output symlink traversal bypassed control-file protection: %v", err) + } +} diff --git a/internal/bundleplan.go b/internal/bundleplan.go new file mode 100644 index 0000000..c69c81f --- /dev/null +++ b/internal/bundleplan.go @@ -0,0 +1,787 @@ +package internal + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "time" + "unicode" + + "github.com/sensiblebit/certkit" + "github.com/sensiblebit/certkit/internal/certstore" +) + +// ErrBundlePlanBlocked indicates that a requested export failed its safety checks. +var ErrBundlePlanBlocked = errors.New("bundle export plan is blocked") + +var errBundlePlanInput = errors.New("invalid bundle export request") + +const ( + bundleManifestName = "manifest.json" + bundleRefreshLockName = ".certkit-refresh.lock" +) + +// BundleLeaf describes a selected or existing certificate without private material. +type BundleLeaf struct { + CommonName string `json:"common_name"` + DNSNames []string `json:"dns_names"` + IPAddresses []string `json:"ip_addresses"` + EmailAddresses []string `json:"email_addresses"` + URIs []string `json:"uris"` + Serial string `json:"serial_number"` + Fingerprint string `json:"sha256_fingerprint"` + NotBefore time.Time `json:"not_before"` + NotAfter time.Time `json:"not_after"` + Source string `json:"source"` + PEM string `json:"pem"` +} + +// BundleRule identifies the configuration entry that matched a certificate. +type BundleRule struct { + ConfigPath string `json:"config_path"` + Index int `json:"index"` + BundleName string `json:"bundle_name"` + CommonNames []string `json:"common_names"` +} + +// BundleChain describes the chain and whether trust verification was performed. +type BundleChain struct { + Status string `json:"status"` + TrustStore string `json:"trust_store"` + Intermediates []string `json:"intermediate_fingerprints"` + Roots []string `json:"root_fingerprints"` + Warnings []string `json:"warnings,omitempty"` +} + +// BundleCandidateDecision records why an alternative certificate was not selected. +type BundleCandidateDecision struct { + Leaf *BundleLeaf `json:"leaf"` + KeySource string `json:"key_source,omitempty"` + Reason string `json:"reason"` +} + +// BundleExportEntry records the decision for one requested bundle directory. +type BundleExportEntry struct { + BundleName string `json:"bundle_name"` + OutputDirectory string `json:"output_directory"` + Rule *BundleRule `json:"rule,omitempty"` + Leaf *BundleLeaf `json:"leaf,omitempty"` + ExistingLeaf *BundleLeaf `json:"existing_leaf,omitempty"` + KeySource string `json:"key_source,omitempty"` + SelectionReason string `json:"selection_reason,omitempty"` + CandidateCount int `json:"candidate_count"` + SkippedCandidates []BundleCandidateDecision `json:"skipped_candidates,omitempty"` + Chain BundleChain `json:"chain"` + Formats []string `json:"formats"` + Files []string `json:"files"` + RemovedFiles []string `json:"removed_files,omitempty"` + Action string `json:"action"` + Status string `json:"status"` + Reason string `json:"reason"` + Forced bool `json:"forced,omitempty"` +} + +// BundlePlanInput configures a managed bundle refresh. It never writes files. +type BundlePlanInput struct { + ExportBundlesInput + ConfigPath string + BundleNames []string + RequireBundles []string + FailOnSkip bool + Formats []string + AllowPrivateNetworks bool + AIATimeout time.Duration + AllowExpired bool + // CustomRoots supplies trust anchors when TrustStore is "custom". + CustomRoots []*x509.Certificate +} + +// BundleExportPlan contains a reviewable manifest and private, in-memory output. +// Use Write to apply the plan after inspecting its entries. +type BundleExportPlan struct { + Entries []BundleExportEntry `json:"exports"` + outDir string + destination bundleDestination + writes []plannedBundleWrite + blocked []string + unselectedFolders map[string]string + allowExpired bool + now func() time.Time +} + +type plannedBundleWrite struct { + entry int + folder string + files []certstore.BundleFile + existing bundleDirectoryState + notBefore time.Time + notAfter time.Time + leaf *x509.Certificate + verify *x509.VerifyOptions +} + +// Validate reports all decisions that prevent this plan from being applied. +func (p *BundleExportPlan) Validate() error { + if len(p.blocked) != 0 { + return fmt.Errorf("%w: %s", ErrBundlePlanBlocked, strings.Join(p.blocked, "; ")) + } + return nil +} + +// PlanBundleExports selects, validates, and renders all requested bundles before +// any output directory is created. Conflicts are returned in the manifest. +func PlanBundleExports(ctx context.Context, input BundlePlanInput) (*BundleExportPlan, error) { + if input.Store == nil || input.OutDir == "" { + return nil, fmt.Errorf("%w: bundle store and output directory are required", errBundlePlanInput) + } + if len(input.Configs) == 0 { + return nil, fmt.Errorf("%w: bundle export requires a nonempty configuration", errBundlePlanInput) + } + if input.BundleNames != nil && len(input.BundleNames) == 0 { + return nil, fmt.Errorf("%w: selected bundle names must not be empty", errBundlePlanInput) + } + formats := input.Formats + if formats == nil { + formats = certstore.DefaultBundleFormats() + } + formats, err := certstore.NormalizeBundleFormats(formats) + if err != nil { + return nil, fmt.Errorf("selecting bundle artifacts: %w", err) + } + if slices.Contains(formats, "p12") && input.P12Password == "" { + input.P12Password = DefaultExportPassword + } + input.Formats = formats + rules, err := bundlePlanRules(input) + if err != nil { + return nil, err + } + names := slices.Clone(input.BundleNames) + if len(names) == 0 { + for name := range rules { + names = append(names, name) + } + } + slices.Sort(names) + names = slices.Compact(names) + // Validate required rules without expanding the explicitly selected scope. + for _, name := range append(slices.Clone(names), input.RequireBundles...) { + if _, ok := rules[name]; !ok { + return nil, fmt.Errorf("%w: bundle %q has no configuration rule", errBundlePlanInput, name) + } + } + for _, name := range input.RequireBundles { + if !slices.Contains(names, name) { + return nil, fmt.Errorf("%w: required bundle %q is outside the selected scope", errBundlePlanInput, name) + } + } + destination, err := planBundleDestination(input.OutDir) + if err != nil { + return nil, err + } + input.OutDir = destination.path + plan := &BundleExportPlan{outDir: input.OutDir, destination: destination, Entries: []BundleExportEntry{}, + unselectedFolders: map[string]string{}, allowExpired: input.AllowExpired, now: time.Now} + for name := range rules { + if !slices.Contains(names, name) { + folder, err := certstore.SanitizeBundleFolder(name) + if err != nil { + return nil, fmt.Errorf("sanitizing unselected bundle %q: %w", name, err) + } + plan.unselectedFolders[name] = folder + } + } + folders := map[string]string{} + primaryFolders := map[string]string{} + for _, name := range names { + folder, err := reserveBundleFolder(folders, name) + if err != nil { + return nil, err + } + primaryFolders[name] = folder + } + for _, name := range names { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("planning bundle export: %w", err) + } + if slices.Contains(formats, "k8s") { + if err := certstore.ValidateK8sSecretName(name); err != nil { + return nil, fmt.Errorf("validating kubernetes secret name for bundle %q: %w", name, err) + } + } + certs := input.Store.CertsByBundleName(name) + if len(certs) == 0 { + entry := BundleExportEntry{BundleName: name, OutputDirectory: filepath.Join(input.OutDir, primaryFolders[name]), + Rule: rules[name], Formats: formats, Files: []string{}, Action: "skip", Status: "skipped", + Reason: "no matching certificate was found", Chain: BundleChain{Status: "not_checked"}} + plan.Entries = append(plan.Entries, entry) + if input.FailOnSkip || slices.Contains(input.RequireBundles, name) || slices.Contains(input.BundleNames, name) { + plan.blocked = append(plan.blocked, name+": "+entry.Reason) + } + continue + } + primaryEntryIndex := len(plan.Entries) + for i, rec := range certs { + if i > 0 && !input.Duplicates { + break + } + folder := primaryFolders[name] + if i > 0 { + folderName := fmt.Sprintf("%s_%s_%s_%s", name, rec.NotAfter.UTC().Format("20060102T150405Z"), rec.Cert.SerialNumber, certkit.CertFingerprint(rec.Cert)[:12]) + folder, err = reserveBundleFolder(folders, folderName) + if err != nil { + return nil, err + } + } + entry, write, err := planBundleCandidate(ctx, planBundleCandidateInput{ + Input: input, Record: rec, Folder: folder, Rule: rules[name], CandidateCount: len(certs), + }) + if err != nil { + return nil, fmt.Errorf("planning bundle %q: %w", name, err) + } + if i == 0 && !input.Duplicates { + for _, skipped := range certs[1:] { + decision := BundleCandidateDecision{Leaf: describeBundleLeaf(skipped.Cert, skipped.Source), + Reason: "higher SHA-256 fingerprint than the selected candidate"} + if skipped.NotAfter.Before(rec.NotAfter) { + decision.Reason = "earlier expiration than the selected candidate" + } else if skipped.Cert.NotBefore.Before(rec.Cert.NotBefore) { + decision.Reason = "earlier issuance time than the selected candidate" + } + if key := input.Store.GetKey(skipped.SKI); key != nil { + decision.KeySource = key.Source + } + entry.SkippedCandidates = append(entry.SkippedCandidates, decision) + } + } + write.entry = len(plan.Entries) + plan.Entries = append(plan.Entries, entry) + if entry.Status == "blocked" || (entry.Status == "skipped" && input.FailOnSkip) { + plan.blocked = append(plan.blocked, name+": "+entry.Reason) + } + if entry.Status == "planned" { + plan.writes = append(plan.writes, write) + } + } + // Required names refer to the primary managed directory. Historical + // duplicates may be skipped unless the caller explicitly fails on skips. + primary := plan.Entries[primaryEntryIndex] + if !input.FailOnSkip && primary.Status == "skipped" && (slices.Contains(input.RequireBundles, name) || slices.Contains(input.BundleNames, name)) { + plan.blocked = append(plan.blocked, name+": "+primary.Reason) + } + } + if err := plan.checkDirectoryScope(); err != nil { + return nil, err + } + return plan, nil +} + +func reserveBundleFolder(folders map[string]string, name string) (string, error) { + folder, err := certstore.SanitizeBundleFolder(name) + if err != nil { + return "", fmt.Errorf("sanitizing bundle %q: %w", name, err) + } + if equalBundlePathNames(folder, bundleRefreshLockName) { + return "", fmt.Errorf("%w: bundle directory %q is reserved for the refresh lock; configure a different bundle name", errBundlePlanInput, folder) + } + if err := validateBundleComponentLength(folder); err != nil { + return "", fmt.Errorf("validating bundle directory: %w", err) + } + if isUnsafeBundleName(folder) { + return "", fmt.Errorf("%w: bundle directory %q uses a reserved or invalid portable name; configure a different bundle name", errBundlePlanInput, folder) + } + for previousFolder, previousName := range folders { + if equalBundlePathNames(previousFolder, folder) { + return "", fmt.Errorf("%w: %q and %q map to the same directory after case and unicode normalization", errExportBundleFolderCollision, previousName, name) + } + } + folders[folder] = name + return folder, nil +} + +// validateBundleComponentLength enforces a portable byte limit independently of +// the current filesystem, including multibyte certificate common names. +func validateBundleComponentLength(name string) error { + const maxBytes = 255 + if len(name) > maxBytes { + return fmt.Errorf("%w: component %q exceeds the %d-byte filename limit", errBundlePlanInput, name, maxBytes) + } + return nil +} + +// isUnsafeBundleName rejects control characters and platform-reserved names in +// managed directories and artifacts, regardless of the planning platform. +func isUnsafeBundleName(folder string) bool { + if strings.ContainsFunc(folder, unicode.IsControl) || strings.HasSuffix(folder, ".") || strings.HasSuffix(folder, " ") { + return true + } + base, _, _ := strings.Cut(folder, ".") + base = strings.ToUpper(strings.TrimRight(base, " ")) + if slices.Contains([]string{"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"}, base) { + return true + } + chars := []rune(base) + if len(chars) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) { + digit := chars[3] + return digit >= '1' && digit <= '9' || digit == '¹' || digit == '²' || digit == '³' + } + return false +} + +// checkDirectoryScope uses directory entry names rather than path lookup, which +// can silently resolve an unselected case variant on case-insensitive systems. +func (p *BundleExportPlan) checkDirectoryScope() error { + if len(p.writes) == 0 { + return nil + } + for _, write := range p.writes { + for _, folder := range p.unselectedFolders { + if equalBundlePathNames(folder, write.folder) { + return fmt.Errorf("%w: selected directory %q also belongs to an unselected configuration rule; configure distinct bundle names", errExportBundleFolderCollision, write.folder) + } + } + } + children, err := os.ReadDir(p.outDir) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("checking bundle output directory names: %w", err) + } + for _, write := range p.writes { + for _, child := range children { + if equalBundlePathNames(child.Name(), write.folder) { + if child.Name() != write.folder { + return fmt.Errorf("%w: selected directory %q aliases existing directory %q; use the exact existing name or configure a distinct bundle name", errExportBundleFolderCollision, write.folder, child.Name()) + } + } + } + } + return nil +} + +func bundlePlanRules(input BundlePlanInput) (map[string]*BundleRule, error) { + rules := map[string]*BundleRule{} + commonNames := map[string]bool{} + for i, cfg := range input.Configs { + if len(cfg.CommonNames) == 0 { + return nil, fmt.Errorf("%w: bundle rule %d has no commonNames", errBundlePlanInput, i+1) + } + for _, cn := range cfg.CommonNames { + if strings.TrimSpace(cn) == "" || commonNames[cn] { + return nil, fmt.Errorf("%w: empty or duplicate common name in bundle rule %d", errBundlePlanInput, i+1) + } + commonNames[cn] = true + name := cfg.BundleName + if name == "" { + name = strings.ReplaceAll(cn, "*", "_") + } + if previous, ok := rules[name]; ok && (cfg.BundleName == "" || previous.Index != i+1) { + return nil, fmt.Errorf("%w: duplicate bundle name %q in configuration", errBundlePlanInput, name) + } + rules[name] = &BundleRule{ConfigPath: input.ConfigPath, Index: i + 1, BundleName: name, CommonNames: slices.Clone(cfg.CommonNames)} + } + } + return rules, nil +} + +type planBundleCandidateInput struct { + Input BundlePlanInput + Record *certstore.CertRecord + Folder string + Rule *BundleRule + CandidateCount int +} + +func planBundleCandidate(ctx context.Context, input planBundleCandidateInput) (BundleExportEntry, plannedBundleWrite, error) { + opts := input.Input + rec := input.Record + entry := BundleExportEntry{BundleName: rec.BundleName, OutputDirectory: filepath.Join(opts.OutDir, input.Folder), + Rule: input.Rule, Leaf: describeBundleLeaf(rec.Cert, rec.Source), CandidateCount: input.CandidateCount, + SelectionReason: "latest not_after, then latest not_before, then lowest SHA-256 fingerprint", + Formats: opts.Formats, Files: []string{}, Status: "planned", Action: "create", Reason: "new bundle", + Chain: BundleChain{Status: "not_checked"}} + key := opts.Store.GetKey(rec.SKI) + if key != nil { + entry.KeySource = key.Source + } + write := plannedBundleWrite{folder: input.Folder, notBefore: rec.Cert.NotBefore, notAfter: rec.Cert.NotAfter} + if key == nil && certstore.BundleFormatsNeedKey(opts.Formats) { + entry.Status, entry.Action, entry.Reason = "skipped", "skip", "no matching private key was found" + return entry, write, nil + } + now := time.Now() + if now.Before(rec.Cert.NotBefore) { + entry.Status, entry.Action, entry.Reason = "skipped", "skip", "certificate is not yet valid" + return entry, write, nil + } + if now.After(rec.Cert.NotAfter) && !opts.AllowExpired { + entry.Status, entry.Action = "skipped", "skip" + entry.Reason = "certificate has expired; use --allow-expired to permit expired leaves" + return entry, write, nil + } + bundleOpts := certkit.DefaultOptions() + bundleOpts.AllowExpired = opts.AllowExpired + bundleOpts.CustomRoots = opts.CustomRoots + bundleOpts.ExtraIntermediates = opts.Store.Intermediates() + bundleOpts.AllowPrivateNetworks = opts.AllowPrivateNetworks + if opts.AIATimeout > 0 { + bundleOpts.AIATimeout = opts.AIATimeout + } + if opts.TrustStore != "" { + bundleOpts.TrustStore = opts.TrustStore + } + bundleOpts.Verify = !opts.ForceBundle + bundle, err := certkit.Bundle(ctx, certkit.BundleInput{Leaf: rec.Cert, Options: bundleOpts}) + if err != nil && opts.AllowSystemFallback && bundleOpts.Verify && bundleOpts.TrustStore != "system" && shouldRetrySystemFallback(err) { + bundleOpts.TrustStore = "system" + bundle, err = certkit.Bundle(ctx, certkit.BundleInput{Leaf: rec.Cert, Options: bundleOpts}) + } + entry.Chain.TrustStore = bundleOpts.TrustStore + if err != nil { + if !isBundleVerificationError(err) { + return entry, write, fmt.Errorf("building candidate chain: %w", err) + } + entry.Status, entry.Action = "skipped", "skip" + entry.Reason = "certificate verification failed: " + err.Error() + entry.Chain.Status = "untrusted" + return entry, write, nil + } + entry.Chain.Status = "verified" + if !bundleOpts.Verify { + entry.Chain.Status = "verification_disabled" + entry.Forced = true + } else { + // Preserve the exact trusted chain that produced the planned artifacts. + // Reverification must not silently substitute a different chain at write time. + write.leaf = bundle.Leaf + write.verify = &x509.VerifyOptions{Roots: x509.NewCertPool(), Intermediates: x509.NewCertPool()} + for _, certificate := range bundle.Roots { + write.verify.Roots.AddCert(certificate) + } + for _, certificate := range bundle.Intermediates { + write.verify.Intermediates.AddCert(certificate) + } + } + entry.Chain.Warnings = bundle.Warnings + for _, cert := range bundle.Intermediates { + entry.Chain.Intermediates = append(entry.Chain.Intermediates, certkit.CertFingerprint(cert)) + } + for _, cert := range bundle.Roots { + entry.Chain.Roots = append(entry.Chain.Roots, certkit.CertFingerprint(cert)) + } + existing, err := inspectBundleDirectory(entry.OutputDirectory) + if err != nil { + return entry, write, err + } + if existing.bundleName != "" && existing.bundleName != rec.BundleName { + return entry, write, fmt.Errorf("%w: existing directory %q belongs to bundle %q instead of %q; configure a distinct bundle name", errExportBundleFolderCollision, input.Folder, existing.bundleName, rec.BundleName) + } + write.existing = existing + entry.ExistingLeaf = existing.leaf + if existing.exists { + entry.Action = "replace" + entry.Reason = "candidate expires later than the existing leaf" + conflict := existing.ambiguity + if existing.leaf != nil { + switch { + case entry.Leaf.Fingerprint == existing.leaf.Fingerprint: + entry.Reason = "same leaf certificate; refresh selected artifacts" + case entry.Leaf.NotAfter.Before(existing.leaf.NotAfter): + conflict = "candidate would downgrade the existing expiration" + case entry.Leaf.NotAfter.Equal(existing.leaf.NotAfter): + conflict = "same expiration with a different certificate fingerprint" + } + } + if conflict != "" { + entry.Reason = conflict + if !opts.ForceBundle { + entry.Status = "blocked" + entry.Reason += "; use --force to explicitly allow replacement" + return entry, write, nil + } + entry.Forced = true + } + } + fileInput := certstore.BundleExportInput{Bundle: bundle, + Prefix: certstore.SanitizeFileName(certstore.FormatCN(rec.Cert)), SecretName: rec.BundleName, + P12Password: opts.P12Password, EncryptKey: opts.EncryptKey, Formats: opts.Formats} + if key != nil { + fileInput.KeyPEM, fileInput.KeyType, fileInput.BitLength = key.PEM, key.KeyType, key.BitLength + } + if subject := opts.Configs[input.Rule.Index-1].Subject; subject != nil { + fileInput.CSRSubject = &certstore.CSRSubjectOverride{Country: subject.Country, Province: subject.Province, + Locality: subject.Locality, Organization: subject.Organization, OrganizationalUnit: subject.OrganizationalUnit} + } + files, err := certstore.GenerateBundleFiles(fileInput) + if err != nil { + return entry, write, fmt.Errorf("generating selected artifacts: %w", err) + } + for _, file := range files { + if err := validateBundleComponentLength(file.Name); err != nil { + return entry, write, fmt.Errorf("validating generated artifact: %w", err) + } + if isUnsafeBundleName(file.Name) { + return entry, write, fmt.Errorf("%w: generated artifact %q uses a reserved or invalid portable name", errBundlePlanInput, file.Name) + } + if strings.EqualFold(file.Name, bundleManifestName) { + return entry, write, fmt.Errorf("%w: generated artifact %q conflicts with the reserved export manifest; omit the json format", errBundlePlanInput, file.Name) + } + entry.Files = append(entry.Files, file.Name) + } + entry.Files = append(entry.Files, bundleManifestName) + for _, name := range existing.files { + if !slices.Contains(entry.Files, name) { + entry.RemovedFiles = append(entry.RemovedFiles, name) + } + } + write.files = files + return entry, write, nil +} + +func describeBundleLeaf(cert *x509.Certificate, source string) *BundleLeaf { + fingerprint := sha256.Sum256(cert.Raw) + leaf := &BundleLeaf{CommonName: cert.Subject.CommonName, DNSNames: slices.Clone(cert.DNSNames), + IPAddresses: certstore.FormatIPAddresses(cert.IPAddresses), EmailAddresses: slices.Clone(cert.EmailAddresses), + Serial: cert.SerialNumber.String(), Fingerprint: hex.EncodeToString(fingerprint[:]), + NotBefore: cert.NotBefore.UTC(), NotAfter: cert.NotAfter.UTC(), Source: source, PEM: certkit.CertToPEM(cert)} + for _, uri := range cert.URIs { + leaf.URIs = append(leaf.URIs, uri.String()) + } + return leaf +} + +// Write applies a validated plan, rechecking existing directories and candidate +// validity and trust before modifying bundles. Each directory is replaced using +// staging and rollback; committed status is retained if backup or lock cleanup fails. +func (p *BundleExportPlan) Write(ctx context.Context) (writeErr error) { + if err := ctx.Err(); err != nil { + return fmt.Errorf("writing bundle plan: %w", err) + } + if err := p.Validate(); err != nil { + return err + } + if len(p.writes) == 0 { + return nil + } + if err := p.destination.check(); err != nil { + return p.blockPendingWrites("bundle output destination changed after planning", err) + } + // The lock serializes cooperating certkit processes. Recheck after acquiring + // it so a preview made before another refresh cannot overwrite that refresh. + //nolint:gosec // The output directory contains public certificates; private artifacts use 0600. + if err := os.MkdirAll(p.outDir, 0o755); err != nil { + return fmt.Errorf("creating bundle output directory: %w", err) + } + if err := p.destination.pinCreatedRoot(); err != nil { + return p.blockPendingWrites("bundle output destination could not be pinned", err) + } + lockRoot, err := os.OpenRoot(p.outDir) + if err != nil { + return fmt.Errorf("opening bundle refresh lock directory: %w", err) + } + defer func() { + if err := lockRoot.Close(); err != nil { + writeErr = errors.Join(writeErr, fmt.Errorf("closing bundle refresh lock directory: %w", err)) + } + }() + children, err := fs.ReadDir(lockRoot.FS(), ".") + if err != nil { + return fmt.Errorf("checking bundle refresh locks: %w", err) + } + for _, child := range children { + if strings.EqualFold(child.Name(), bundleRefreshLockName) { + return fmt.Errorf("acquiring bundle refresh lock (existing marker %q): %w", child.Name(), os.ErrExist) + } + } + lockPath := filepath.Join(p.outDir, bundleRefreshLockName) + if err := lockRoot.Mkdir(bundleRefreshLockName, 0o700); err != nil { + return fmt.Errorf("acquiring bundle refresh lock (another refresh may be running): %w", err) + } + lockIdentity, err := lockRoot.Lstat(bundleRefreshLockName) + if err != nil { + return fmt.Errorf("checking acquired bundle refresh lock %q: %w", lockPath, err) + } + checkLock := func() error { + current, err := lockRoot.Lstat(bundleRefreshLockName) + if err != nil { + return p.blockPendingWrites("bundle refresh lock could not be checked", fmt.Errorf("%w: checking bundle refresh lock: %w", ErrBundlePlanBlocked, err)) + } + if !os.SameFile(lockIdentity, current) { + return p.blockPendingWrites("bundle refresh lock changed during export", fmt.Errorf("%w: bundle refresh lock changed during export", ErrBundlePlanBlocked)) + } + return nil + } + defer func() { + err := checkLock() + if err == nil { + err = lockRoot.Remove(bundleRefreshLockName) + } + if err != nil { + writeErr = errors.Join(writeErr, fmt.Errorf("removing bundle refresh lock %q: %w", lockPath, err)) + } + }() + if err := p.destination.check(); err != nil { + return p.blockPendingWrites("bundle output destination changed after planning", err) + } + if err := p.checkDirectoryScopeAtWrite(); err != nil { + return err + } + for _, write := range p.writes { + if err := p.checkCandidateAtWrite(write); err != nil { + return err + } + if err := p.checkExistingAtWrite(write); err != nil { + return err + } + } + for _, write := range p.writes { + if err := ctx.Err(); err != nil { + return fmt.Errorf("writing bundle plan: %w", err) + } + checkWrite := func() error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("committing bundle plan: %w", err) + } + if err := p.destination.check(); err != nil { + return p.blockPendingWrites("bundle output destination changed after planning", err) + } + if err := checkLock(); err != nil { + return err + } + if err := p.checkDirectoryScopeAtWrite(); err != nil { + return err + } + if err := p.checkCandidateAtWrite(write); err != nil { + return err + } + if err := p.checkExistingAtWrite(write); err != nil { + return err + } + return nil + } + var entry BundleExportEntry + writer := &filesystemWriter{outDir: p.outDir, checkCommit: func(parent *os.Root) error { + if err := checkWrite(); err != nil { + return err + } + if err := p.destination.checkRoot(parent); err != nil { + return p.blockPendingWrites("opened bundle output directory changed after planning", err) + } + return checkLock() + }, beforeCommit: func(stagingDir *os.Root) error { + if err := checkWrite(); err != nil { + return err + } + entry = p.Entries[write.entry] + entry.Status = "created" + if entry.Action == "replace" { + entry.Status = "replaced" + } + data, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return fmt.Errorf("encoding export manifest: %w", err) + } + // The manifest contains public metadata, never keys or passwords. + if err := exporterWriteFile(stagingDir, certstore.BundleFile{Name: bundleManifestName, Data: append(data, '\n')}); err != nil { + return fmt.Errorf("writing export manifest: %w", err) + } + return nil + }} + if err := writer.WriteBundleFiles(write.folder, write.files); err != nil { + if errors.Is(err, errExportBundleCommittedCleanup) { + p.Entries[write.entry] = entry + } + return fmt.Errorf("writing bundle %q: %w", p.Entries[write.entry].BundleName, err) + } + p.Entries[write.entry] = entry + } + return nil +} + +// blockPendingWrites records a shared write-time safety failure without changing +// the status of bundles that have already committed. +func (p *BundleExportPlan) blockPendingWrites(reason string, cause error) error { + for _, write := range p.writes { + entry := &p.Entries[write.entry] + if entry.Status == "planned" { + entry.Status = "blocked" + entry.Reason = reason + "; rerun the command: " + cause.Error() + p.blocked = append(p.blocked, entry.BundleName+": "+entry.Reason) + } + } + return errors.Join(p.Validate(), cause) +} + +// checkDirectoryScopeAtWrite invalidates pending entries if directory aliases +// change after review, while retaining the status of already committed bundles. +func (p *BundleExportPlan) checkDirectoryScopeAtWrite() error { + if err := p.checkDirectoryScope(); err != nil { + return fmt.Errorf("rechecking bundle directory scope: %w", p.blockPendingWrites("bundle directory scope changed after planning", err)) + } + return nil +} + +func (p *BundleExportPlan) checkExistingAtWrite(write plannedBundleWrite) error { + entry := &p.Entries[write.entry] + current, err := inspectBundleDirectory(entry.OutputDirectory) + if err != nil { + entry.Status = "blocked" + entry.Reason = "existing bundle could not be reinspected after planning; rerun the command: " + err.Error() + p.blocked = append(p.blocked, entry.BundleName+": "+entry.Reason) + return fmt.Errorf("rechecking bundle %q before write: %w", entry.BundleName, errors.Join(p.Validate(), err)) + } + if current.exists != write.existing.exists || current.digest != write.existing.digest { + entry.Status, entry.Reason = "blocked", "existing bundle changed after planning; rerun the command" + p.blocked = append(p.blocked, entry.BundleName+": "+entry.Reason) + return p.Validate() + } + return nil +} + +func (p *BundleExportPlan) checkCandidateAtWrite(write plannedBundleWrite) error { + now := p.now() + reason := "" + entry := &p.Entries[write.entry] + var verificationErr error + if now.Before(write.notBefore) { + reason = "certificate is not yet valid at write time" + } else if !p.allowExpired && now.After(write.notAfter) { + reason = "certificate expired after planning; rerun with --allow-expired to permit expired leaves" + } + if reason == "" && write.verify != nil { + opts := *write.verify + opts.CurrentTime = now + if p.allowExpired && now.After(write.notAfter) { + opts.CurrentTime = write.notBefore + } + if _, err := write.leaf.Verify(opts); err != nil { + verificationErr = err + reason = "planned chain verification failed at write time: " + err.Error() + entry.Chain.Status = "untrusted" + } else if now.After(write.notAfter) { + warning := "expired leaf: chain verified at " + opts.CurrentTime.UTC().Format(time.RFC3339) + if !slices.Contains(entry.Chain.Warnings, warning) { + entry.Chain.Warnings = append(entry.Chain.Warnings, warning) + } + } + } + if reason == "" { + return nil + } + entry.Status, entry.Reason = "blocked", reason + p.blocked = append(p.blocked, entry.BundleName+": "+reason) + if verificationErr != nil { + return fmt.Errorf("%w: %w", p.Validate(), verificationErr) + } + return p.Validate() +} diff --git a/internal/bundleplan_destination_test.go b/internal/bundleplan_destination_test.go new file mode 100644 index 0000000..c226a9a --- /dev/null +++ b/internal/bundleplan_destination_test.go @@ -0,0 +1,138 @@ +package internal + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestBundlePlan_PinsRelativeDestination(t *testing.T) { + fixture := newBundlePlanFixture(t) + first, second := t.TempDir(), t.TempDir() + t.Chdir(first) + fixture.input.OutDir = "bundles" + fixture.input.Formats = []string{"pem", "key"} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if !filepath.IsAbs(plan.Entries[0].OutputDirectory) { + t.Fatal("review manifest did not identify an absolute destination") + } + t.Chdir(second) + if err := plan.Write(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(first, "bundles", "service-tls", "service.example.com.key")); err != nil { + t.Fatalf("reviewed destination was not written: %v", err) + } + if _, err := os.Stat(filepath.Join(second, "bundles")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("working directory change redirected the write: %v", err) + } +} + +func TestBundlePlan_PinsSymlinkDestination(t *testing.T) { + t.Parallel() + for _, existing := range []bool{false, true} { + name := "missing output" + if existing { + name = "existing output" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + root := t.TempDir() + reviewed, other := filepath.Join(root, "reviewed"), filepath.Join(root, "other") + if existing { + if err := os.Mkdir(reviewed, 0700); err != nil { + t.Fatal(err) + } + } + if err := os.Mkdir(other, 0700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "bundles") + createSymlinkOrSkip(t, reviewed, alias) + fixture.input.OutDir, fixture.input.Formats = alias, []string{"pem", "key"} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(alias); err != nil { + t.Fatal(err) + } + createSymlinkOrSkip(t, other, alias) + if err := plan.Write(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(reviewed, "service-tls", "service.example.com.key")); err != nil { + t.Fatalf("reviewed symlink target was not written: %v", err) + } + children, err := os.ReadDir(other) + if err != nil || len(children) != 0 { + t.Fatalf("retargeted alias received output: %v, %v", children, err) + } + }) + } +} + +func TestBundlePlan_RejectsChangedDestinationIdentity(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + existing bool + symlink bool + }{ + {"existing output replaced", true, false}, + {"existing output redirected", true, true}, + {"missing output ancestor replaced", false, false}, + {"missing output ancestor redirected", false, true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + root := t.TempDir() + anchor := filepath.Join(root, "reviewed") + other := filepath.Join(root, "other") + for _, path := range []string{anchor, other} { + if err := os.Mkdir(path, 0700); err != nil { + t.Fatal(err) + } + } + fixture.input.OutDir = anchor + if !test.existing { + fixture.input.OutDir = filepath.Join(anchor, "future", "bundles") + } + fixture.input.Formats = []string{"pem", "key"} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := os.Rename(anchor, anchor+"-original"); err != nil { + t.Fatal(err) + } + if test.symlink { + createSymlinkOrSkip(t, other, anchor) + } else if err := os.Mkdir(anchor, 0700); err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("changed destination was not blocked: %v", err) + } + if plan.Entries[0].Status != "blocked" || plan.Entries[0].Reason == "" { + t.Fatalf("changed destination did not invalidate the entry: %+v", plan.Entries[0]) + } + if !errors.Is(plan.Validate(), ErrBundlePlanBlocked) { + t.Fatal("changed destination left the plan valid") + } + for _, path := range []string{anchor, anchor + "-original", other} { + children, err := os.ReadDir(path) + if err != nil || len(children) != 0 { + t.Fatalf("changed destination created outputs in %s: %v, %v", path, children, err) + } + } + }) + } +} diff --git a/internal/bundleplan_duplicates_test.go b/internal/bundleplan_duplicates_test.go new file mode 100644 index 0000000..e0df915 --- /dev/null +++ b/internal/bundleplan_duplicates_test.go @@ -0,0 +1,141 @@ +package internal + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sensiblebit/certkit/internal/certstore" + "gopkg.in/yaml.v3" +) + +func TestBundlePlan_DuplicateSecretNames(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + older := resignBundleLeaf(t, resignBundleLeafInput{Fixture: fixture, Serial: 42, + NotBefore: fixture.leaf.cert.NotBefore, NotAfter: fixture.leaf.cert.NotAfter.Add(-time.Hour)}) + if err := fixture.input.Store.HandleCertificate(older, "older.pem"); err != nil { + t.Fatal(err) + } + AssignBundleNames(fixture.input.Store, fixture.input.Configs) + fixture.input.Duplicates = true + fixture.input.Formats = []string{"k8s"} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); err != nil { + t.Fatal(err) + } + if len(plan.Entries) != 2 || plan.Entries[0].OutputDirectory == plan.Entries[1].OutputDirectory { + t.Fatalf("expected two distinct bundle directories: %+v", plan.Entries) + } + for _, entry := range plan.Entries { + var secret certstore.K8sSecret + data := mustReadTestFile(t, filepath.Join(entry.OutputDirectory, "service.example.com.k8s.yaml")) + if err := yaml.Unmarshal(data, &secret); err != nil { + t.Fatal(err) + } + if secret.Metadata.Name != "service-tls" { + t.Fatalf("secret name = %q, want configured bundle name", secret.Metadata.Name) + } + if err := certstore.ValidateK8sSecretName(secret.Metadata.Name); err != nil { + t.Fatalf("duplicate secret cannot be applied: %v", err) + } + } +} + +func TestBundlePlan_DuplicateRequirements(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + required bool + scoped bool + failOnSkip bool + primaryKey bool + olderKey bool + wantBlocked bool + }{ + {"required bundle tolerates skipped history", true, false, false, true, false, false}, + {"scoped bundle tolerates skipped history", false, true, false, true, false, false}, + {"fail on skip rejects skipped history", true, false, true, true, false, true}, + {"required primary cannot be replaced by history", true, false, false, false, true, true}, + {"scoped primary cannot be replaced by history", false, true, false, false, true, true}, + {"optional primary may be skipped", false, false, false, false, true, false}, + {"no candidate can satisfy requirement", true, false, false, false, false, true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + olderKey := newECDSALeaf(t, fixture.ca, fixture.leaf.cert.Subject.CommonName, nil) + older := resignBundleLeaf(t, resignBundleLeafInput{ + Fixture: bundlePlanFixture{ca: fixture.ca, leaf: olderKey}, Serial: 42, + NotBefore: fixture.leaf.cert.NotBefore, NotAfter: fixture.leaf.cert.NotAfter.Add(-time.Hour)}) + store := certstore.NewMemStore() + if err := store.HandleCertificate(fixture.leaf.cert, "latest.pem"); err != nil { + t.Fatal(err) + } + if err := store.HandleCertificate(older, "older.pem"); err != nil { + t.Fatal(err) + } + if test.primaryKey { + if err := store.HandleKey(fixture.leaf.key, fixture.leaf.keyPEM, "latest.key"); err != nil { + t.Fatal(err) + } + } + if test.olderKey { + if err := store.HandleKey(olderKey.key, olderKey.keyPEM, "older.key"); err != nil { + t.Fatal(err) + } + } + AssignBundleNames(store, fixture.input.Configs) + fixture.input.Store = store + fixture.input.Duplicates, fixture.input.FailOnSkip = true, test.failOnSkip + fixture.input.Formats = []string{"key"} + if test.required { + fixture.input.RequireBundles = []string{"service-tls"} + } + if test.scoped { + fixture.input.BundleNames = []string{"service-tls"} + } + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if len(plan.Entries) != 2 { + t.Fatalf("entries = %d, want every duplicate decision", len(plan.Entries)) + } + for _, entry := range plan.Entries { + if entry.Status == "skipped" && !strings.Contains(entry.Reason, "private key") { + t.Fatalf("missing skip reason: %+v", entry) + } + } + err = plan.Write(context.Background()) + if test.wantBlocked { + if !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("write error = %v, want blocked plan", err) + } + if _, err := os.Stat(fixture.input.OutDir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("blocked duplicate plan wrote output") + } + return + } + if err != nil { + t.Fatal(err) + } + for _, entry := range plan.Entries { + _, err := os.Stat(entry.OutputDirectory) + if entry.Status == "created" && err != nil { + t.Fatalf("successful candidate was not written: %v", err) + } + if entry.Status == "skipped" && !errors.Is(err, os.ErrNotExist) { + t.Fatal("skipped candidate wrote a directory") + } + } + }) + } +} diff --git a/internal/bundleplan_existing.go b/internal/bundleplan_existing.go new file mode 100644 index 0000000..6c38b37 --- /dev/null +++ b/internal/bundleplan_existing.go @@ -0,0 +1,185 @@ +package internal + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/sensiblebit/certkit" + "gopkg.in/yaml.v3" +) + +var errBundleInspection = errors.New("cannot safely inspect existing bundle") + +type bundleDirectoryState struct { + exists bool + bundleName string + digest string + leaf *BundleLeaf + ambiguity string + files []string +} + +// inspectBundleDirectory reads the existing public certificate and fingerprints +// directory contents so a stale plan cannot silently replace changed files. +func inspectBundleDirectory(path string) (bundleDirectoryState, error) { + state := bundleDirectoryState{} + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return state, nil + } + if err != nil { + return state, fmt.Errorf("checking existing bundle: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return state, fmt.Errorf("%w: existing bundle %s must be a directory, not a symlink or file", errBundleInspection, path) + } + state.exists = true + root, err := os.OpenRoot(path) + if err != nil { + return state, fmt.Errorf("opening existing bundle directory: %w", err) + } + defer func() { + if err := root.Close(); err != nil { + slog.Warn("closing existing bundle directory", "error", err) + } + }() + hash := sha256.New() + leaves := map[string]*BundleLeaf{} + if err := fs.WalkDir(root.FS(), ".", func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return fmt.Errorf("reading existing bundle entry: %w", walkErr) + } + if name == "." { + return nil + } + state.files = append(state.files, name) + if !entry.Type().IsRegular() { + return fmt.Errorf("%w: existing bundle contains a non-regular artifact %q; move it aside before refreshing", errBundleInspection, name) + } + data, mode, err := readExistingBundleArtifact(root, name) + if err != nil { + return fmt.Errorf("inspecting artifact %q: %w", name, err) + } + if _, err := fmt.Fprintf(hash, "%q:%d:%x\n", name, mode, sha256.Sum256(data)); err != nil { + return fmt.Errorf("fingerprinting existing bundle: %w", err) + } + var pemData string + foldedName := strings.ToLower(name) + switch { + case strings.HasSuffix(foldedName, ".pem"): + pemData = string(data) + case strings.EqualFold(name, bundleManifestName): + var manifest BundleExportEntry + if err := json.Unmarshal(data, &manifest); err != nil { + state.ambiguity = fmt.Sprintf("existing export manifest %q is invalid", name) + } else { + if strings.TrimSpace(manifest.BundleName) == "" { + state.ambiguity = fmt.Sprintf("existing export manifest %q is missing bundle_name", name) + } else { + if state.bundleName != "" && state.bundleName != manifest.BundleName { + return fmt.Errorf("%w: existing manifests identify different bundles", errBundleInspection) + } + state.bundleName = manifest.BundleName + } + if manifest.Leaf == nil || strings.TrimSpace(manifest.Leaf.PEM) == "" { + state.ambiguity = fmt.Sprintf("existing export manifest %q is missing leaf.pem", name) + } else { + pemData = manifest.Leaf.PEM + } + } + case strings.HasSuffix(foldedName, ".json") && !strings.HasSuffix(foldedName, ".csr.json"): + var metadata struct { + PEM string `json:"pem"` + } + if err := json.Unmarshal(data, &metadata); err == nil { + pemData = metadata.PEM + if strings.TrimSpace(pemData) == "" { + state.ambiguity = fmt.Sprintf("existing JSON certificate artifact %q is missing pem", name) + } + } else { + state.ambiguity = fmt.Sprintf("existing JSON certificate artifact %q is invalid", name) + } + case strings.HasSuffix(foldedName, ".yaml") && !strings.HasSuffix(foldedName, ".k8s.yaml"): + var metadata struct { + CRT string `yaml:"crt"` + } + if err := yaml.Unmarshal(data, &metadata); err == nil { + pemData = metadata.CRT + if strings.TrimSpace(pemData) == "" { + state.ambiguity = fmt.Sprintf("existing YAML certificate artifact %q is missing crt", name) + } + } else { + state.ambiguity = fmt.Sprintf("existing YAML certificate artifact %q is invalid", name) + } + } + if pemData != "" { + certs, err := certkit.ParsePEMCertificates([]byte(pemData)) + if err != nil { + state.ambiguity = fmt.Sprintf("existing certificate artifact %q cannot be parsed", name) + } + for _, cert := range certs { + // A manifest identifies the selected certificate, which may itself + // be a CA. Other artifacts can contain unrelated chain CAs. + if !cert.IsCA || strings.EqualFold(name, bundleManifestName) { + leaf := describeBundleLeaf(cert, filepath.Join(path, name)) + if _, exists := leaves[leaf.Fingerprint]; !exists { + leaves[leaf.Fingerprint] = leaf + } + } + } + } + return nil + }); err != nil { + return state, fmt.Errorf("inspecting existing bundle contents: %w", err) + } + state.digest = hex.EncodeToString(hash.Sum(nil)) + switch len(leaves) { + case 0: + if state.ambiguity == "" { + state.ambiguity = "existing directory has no identifiable leaf certificate" + } + case 1: + for _, leaf := range leaves { + state.leaf = leaf + } + default: + if state.ambiguity == "" { + state.ambiguity = "existing directory contains multiple different leaf certificates" + } + } + return state, nil +} + +func readExistingBundleArtifact(root *os.Root, name string) ([]byte, fs.FileMode, error) { + file, err := root.Open(name) + if err != nil { + return nil, 0, fmt.Errorf("opening existing artifact %q: %w", name, err) + } + defer func() { + if err := file.Close(); err != nil { + slog.Warn("closing existing bundle artifact", "file", name, "error", err) + } + }() + info, err := file.Stat() + if err != nil { + return nil, 0, fmt.Errorf("checking existing artifact %q: %w", name, err) + } + const limit = 64 << 20 + data, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil { + return nil, 0, fmt.Errorf("reading existing artifact %q: %w", name, err) + } + if len(data) > limit { + return nil, 0, fmt.Errorf("%w: existing artifact %q exceeds the 64 MiB inspection limit", errBundleInspection, name) + } + return data, info.Mode(), nil +} diff --git a/internal/bundleplan_existing_test.go b/internal/bundleplan_existing_test.go new file mode 100644 index 0000000..e5e23b0 --- /dev/null +++ b/internal/bundleplan_existing_test.go @@ -0,0 +1,565 @@ +package internal + +import ( + "context" + "crypto/x509" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sensiblebit/certkit" + "github.com/sensiblebit/certkit/internal/certstore" +) + +func TestBundlePlan_PreservesMalformedArtifactDiagnostics(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + filename string + contents string + leafCount int + }{ + {"malformed only certificate", "broken.pem", "not a PEM certificate", 0}, + {"malformed alongside multiple leaves", "broken.pem", "not a PEM certificate", 2}, + {"malformed manifest", "MANIFEST.JSON", "{", 0}, + {"malformed JSON without leaf", "broken.json", "{", 0}, + {"malformed JSON alongside valid leaf", "broken.json", "{", 1}, + {"invalid JSON certificate field", "broken.json", `{"pem": 123}`, 1}, + {"malformed YAML without leaf", "broken.yaml", "crt: [", 0}, + {"malformed YAML alongside valid leaf", "broken.yaml", "crt: [", 1}, + {"invalid YAML certificate field", "broken.yaml", "crt: [invalid]", 1}, + {"empty JSON object", "broken.json", "{}", 1}, + {"null JSON", "broken.json", "null", 1}, + {"empty JSON certificate", "broken.json", `{"pem":""}`, 1}, + {"empty YAML object", "broken.yaml", "{}", 1}, + {"null YAML", "broken.yaml", "null", 1}, + {"empty YAML certificate", "broken.yaml", "crt: ''", 1}, + {"empty manifest", "manifest.json", "{}", 1}, + {"null manifest", "manifest.json", "null", 1}, + {"manifest without leaf", "manifest.json", `{"bundle_name":"service-tls"}`, 1}, + {"manifest without certificate", "manifest.json", `{"bundle_name":"service-tls","leaf":{}}`, 1}, + {"mixed case malformed JSON", "broken.JsOn", "{", 1}, + {"mixed case malformed YAML", "broken.YaMl", "crt: [", 1}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + fixture.input.Formats = []string{"pem"} + fixture.input.ForceBundle = false + fixture.input.TrustStore, fixture.input.CustomRoots = "custom", []*x509.Certificate{fixture.ca.cert} + directory := filepath.Join(fixture.input.OutDir, "service-tls") + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(directory, test.filename) + if err := os.WriteFile(path, []byte(test.contents), 0600); err != nil { + t.Fatal(err) + } + if test.leafCount > 0 { + leaves := certkit.CertToPEM(fixture.leaf.cert) + if test.leafCount > 1 { + other := newECDSALeaf(t, fixture.ca, "other.example.com", nil) + leaves += certkit.CertToPEM(other.cert) + } + if err := os.WriteFile(filepath.Join(directory, "leaves.pem"), []byte(leaves), 0600); err != nil { + t.Fatal(err) + } + } + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if !errors.Is(plan.Validate(), ErrBundlePlanBlocked) || !strings.Contains(plan.Entries[0].Reason, test.filename) { + t.Fatalf("specific malformed artifact was hidden: %+v", plan.Entries[0]) + } + if err := plan.Write(context.Background()); !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("malformed existing output was replaced: %v", err) + } + if string(mustReadTestFile(t, path)) != test.contents { + t.Fatal("malformed artifact was overwritten") + } + }) + } +} + +func TestBundlePlan_ManifestRequiresBundleName(t *testing.T) { + t.Parallel() + for _, name := range []string{"", " \t"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + fixture.input.Formats = []string{"pem"} + initial, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := initial.Write(context.Background()); err != nil { + t.Fatal(err) + } + manifest := initial.Entries[0] + manifest.BundleName = name + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(manifest.OutputDirectory, bundleManifestName) + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + fixture.input.ForceBundle = false + fixture.input.TrustStore, fixture.input.CustomRoots = "custom", []*x509.Certificate{fixture.ca.cert} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("incomplete manifest was replaced: %v", err) + } + if !strings.Contains(plan.Entries[0].Reason, bundleManifestName) { + t.Fatalf("missing manifest diagnostic: %s", plan.Entries[0].Reason) + } + if string(mustReadTestFile(t, path)) != string(data) { + t.Fatal("incomplete manifest was overwritten") + } + }) + } +} + +func TestBundlePlan_InspectsMixedCaseArtifacts(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + field string + ignored bool + }{ + {"other.PEM", "", false}, + {"other.JsOn", "pem", false}, + {"other.YaMl", "crt", false}, + {"other.CsR.JsOn", "pem", true}, + {"other.K8s.YaMl", "crt", true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + fixture.input.Formats = []string{"pem"} + initial, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := initial.Write(context.Background()); err != nil { + t.Fatal(err) + } + other := newECDSALeaf(t, fixture.ca, "other.example.com", nil) + data := []byte(certkit.CertToPEM(other.cert)) + if test.field != "" { + data, err = json.Marshal(map[string]string{test.field: string(data)}) + if err != nil { + t.Fatal(err) + } + } + path := filepath.Join(initial.Entries[0].OutputDirectory, test.name) + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + fixture.input.ForceBundle = false + fixture.input.TrustStore, fixture.input.CustomRoots = "custom", []*x509.Certificate{fixture.ca.cert} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + err = plan.Write(context.Background()) + if test.ignored { + if err != nil { + t.Fatalf("non-certificate metadata blocked refresh: %v", err) + } + } else { + if !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("mixed-case conflicting leaf was replaced: %v", err) + } + if string(mustReadTestFile(t, path)) != string(data) { + t.Fatal("conflicting artifact was overwritten") + } + } + }) + } +} + +func TestBundlePlan_SkipsUnusableReplacementCandidates(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + expired bool + requirement string + allowExpired bool + publicOnly bool + wantStatus string + wantReason string + }{ + {"optional keyless leaf", false, "", false, false, "skipped", "private key"}, + {"required keyless leaf", false, "required", false, false, "skipped", "private key"}, + {"selected keyless leaf", false, "selected", false, false, "skipped", "private key"}, + {"fail on keyless skip", false, "fail-on-skip", false, false, "skipped", "private key"}, + {"optional expired leaf", true, "", false, false, "skipped", "--allow-expired"}, + {"required expired leaf", true, "required", false, false, "skipped", "--allow-expired"}, + {"selected expired leaf", true, "selected", false, false, "skipped", "--allow-expired"}, + {"fail on expired skip", true, "fail-on-skip", false, false, "skipped", "--allow-expired"}, + {"allowed expired leaf still protects replacement", true, "", true, false, "blocked", "downgrade"}, + {"public only keyless leaf still protects replacement", false, "", false, true, "blocked", "downgrade"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + candidate := fixture.leaf.cert + if test.expired { + candidate = resignBundleLeaf(t, resignBundleLeafInput{Fixture: fixture, Serial: 11, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(-time.Minute)}) + } + store := certstore.NewMemStore() + if err := store.HandleCertificate(candidate, "old-delivery.pem"); err != nil { + t.Fatal(err) + } + if test.expired { + if err := store.HandleKey(fixture.leaf.key, fixture.leaf.keyPEM, "old-delivery.key"); err != nil { + t.Fatal(err) + } + } + AssignBundleNames(store, fixture.input.Configs) + fixture.input.Store, fixture.input.ForceBundle = store, false + fixture.input.TrustStore = "custom" + fixture.input.CustomRoots = []*x509.Certificate{fixture.ca.cert} + fixture.input.AllowExpired = test.allowExpired + if test.publicOnly { + fixture.input.Formats = []string{"pem"} + } + switch test.requirement { + case "required": + fixture.input.RequireBundles = []string{"service-tls"} + case "selected": + fixture.input.BundleNames = []string{"service-tls"} + case "fail-on-skip": + fixture.input.FailOnSkip = true + } + installed := resignBundleLeaf(t, resignBundleLeafInput{Fixture: fixture, Serial: 42, + NotBefore: fixture.leaf.cert.NotBefore, NotAfter: fixture.leaf.cert.NotAfter.Add(time.Hour)}) + dir := filepath.Join(fixture.input.OutDir, "service-tls") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "installed.pem") + original := certkit.CertToPEM(installed) + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + entry := plan.Entries[0] + if entry.Status != test.wantStatus || !strings.Contains(entry.Reason, test.wantReason) { + t.Fatalf("unexpected replacement decision: %+v", entry) + } + err = plan.Write(context.Background()) + wantBlocked := test.requirement != "" || test.wantStatus == "blocked" + if wantBlocked { + if !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("write error = %v, want blocked plan", err) + } + } else if err != nil { + t.Fatalf("optional skip blocked the plan: %v", err) + } + if string(mustReadTestFile(t, path)) != original { + t.Fatal("unusable candidate changed the installed certificate") + } + files, err := os.ReadDir(dir) + if err != nil || len(files) != 1 { + t.Fatalf("unexpected artifacts in untouched directory: %v, %v", files, err) + } + }) + } +} + +func TestBundlePlan_BlocksFailedReinspection(t *testing.T) { + t.Parallel() + for _, scenario := range []string{"bundle becomes a file", "bundle becomes a symlink", "artifact becomes a symlink", "nested directory appears"} { + t.Run(scenario, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + fixture.input.Formats = []string{"pem"} + initial, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := initial.Write(context.Background()); err != nil { + t.Fatal(err) + } + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + directory := plan.Entries[0].OutputDirectory + switch scenario { + case "bundle becomes a file", "bundle becomes a symlink": + if err := os.Rename(directory, directory+"-saved"); err != nil { + t.Fatal(err) + } + if scenario == "bundle becomes a file" { + if err := os.WriteFile(directory, []byte("external file"), 0600); err != nil { + t.Fatal(err) + } + } else { + createSymlinkOrSkip(t, directory+"-saved", directory) + } + case "artifact becomes a symlink": + artifact := filepath.Join(directory, bundleManifestName) + if err := os.Rename(artifact, artifact+"-saved"); err != nil { + t.Fatal(err) + } + createSymlinkOrSkip(t, artifact+"-saved", artifact) + case "nested directory appears": + if err := os.Mkdir(filepath.Join(directory, "external"), 0700); err != nil { + t.Fatal(err) + } + } + err = plan.Write(context.Background()) + if !errors.Is(err, ErrBundlePlanBlocked) || !errors.Is(err, errBundleInspection) { + t.Fatalf("failed reinspection did not preserve validation and inspection errors: %v", err) + } + entry := plan.Entries[0] + if entry.Status != "blocked" || !strings.Contains(entry.Reason, "reinspect") { + t.Fatalf("failed reinspection reported as writable: %+v", entry) + } + if !errors.Is(plan.Validate(), ErrBundlePlanBlocked) { + t.Fatal("rejected plan remained valid") + } + }) + } +} + +func TestBundlePlan_FutureCertificatesCannotReplaceExistingBundles(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + force bool + allowExpired bool + requirement string + }{ + {"verified", false, false, ""}, + {"forced", true, false, ""}, + {"forced with expired allowed", true, true, ""}, + {"required forced export", true, false, "required"}, + {"selected forced export", true, false, "selected"}, + {"fail on forced skip", true, false, "fail-on-skip"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + future := resignBundleLeaf(t, resignBundleLeafInput{Fixture: fixture, Serial: 42, + NotBefore: time.Now().Add(time.Hour), NotAfter: fixture.leaf.cert.NotAfter.Add(24 * time.Hour)}) + if err := fixture.input.Store.HandleCertificate(future, "future.pem"); err != nil { + t.Fatal(err) + } + AssignBundleNames(fixture.input.Store, fixture.input.Configs) + fixture.input.ForceBundle, fixture.input.AllowExpired = test.force, test.allowExpired + switch test.requirement { + case "required": + fixture.input.RequireBundles = []string{"service-tls"} + case "selected": + fixture.input.BundleNames = []string{"service-tls"} + case "fail-on-skip": + fixture.input.FailOnSkip = true + } + dir := filepath.Join(fixture.input.OutDir, "service-tls") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "installed.pem") + original := certkit.CertToPEM(fixture.leaf.cert) + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + entry := plan.Entries[0] + if entry.Status != "skipped" || entry.Reason != "certificate is not yet valid" || entry.Leaf.Fingerprint != certkit.CertFingerprint(future) { + t.Fatalf("future candidate was exported or silently replaced with an older candidate: %+v", entry) + } + err = plan.Write(context.Background()) + if test.requirement != "" && !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("required future bundle did not block writing: %v", err) + } + if test.requirement == "" && err != nil { + t.Fatalf("optional future bundle was not skipped: %v", err) + } + if string(mustReadTestFile(t, path)) != original { + t.Fatal("future certificate replaced the existing bundle") + } + }) + } +} + +func TestBundlePlan_CSRMetadataDoesNotBlockRefresh(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + legacy bool + corruptPEM bool + }{ + {"legacy complete bundle", true, false}, + {"managed complete bundle", false, false}, + {"corrupt certificate metadata remains protected", true, true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + fixture.input.Formats = certstore.BundleFormats() + fixture.input.ForceBundle = false + fixture.input.TrustStore = "custom" + fixture.input.CustomRoots = []*x509.Certificate{fixture.ca.cert} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); err != nil { + t.Fatal(err) + } + dir := plan.Entries[0].OutputDirectory + if test.legacy { + if err := os.Remove(filepath.Join(dir, "manifest.json")); err != nil { + t.Fatal(err) + } + } + if test.corruptPEM { + if err := os.WriteFile(filepath.Join(dir, "service.example.com.json"), []byte(`{"pem":"invalid certificate"}`), 0600); err != nil { + t.Fatal(err) + } + } + refresh, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + entry := refresh.Entries[0] + if entry.ExistingLeaf == nil || entry.ExistingLeaf.Fingerprint != certkit.CertFingerprint(fixture.leaf.cert) { + t.Fatal("existing certificate identity was lost") + } + if test.corruptPEM { + if !errors.Is(refresh.Validate(), ErrBundlePlanBlocked) || !strings.Contains(entry.Reason, `"service.example.com.json" cannot be parsed`) { + t.Fatalf("corrupt certificate metadata did not identify its artifact: %+v", entry) + } + return + } + if entry.Status != "planned" || entry.Chain.Status != "verified" { + t.Fatalf("CSR metadata incorrectly blocked replacement: %+v", entry) + } + if refresh.Entries[0].Reason != "same leaf certificate; refresh selected artifacts" { + t.Fatal("valid CSR metadata still requires a replacement override") + } + if err := refresh.Write(context.Background()); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestBundlePlan_ManagedCAReplacement(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + intermediate bool + formats []string + manifest string + }{ + {"root with public artifacts", false, []string{"pem", "fullchain", "json"}, ""}, + {"root with key only", false, []string{"key"}, ""}, + {"intermediate with full chain", true, []string{"pem", "fullchain", "json"}, ""}, + {"intermediate with key only", true, []string{"key"}, ""}, + {"root with uppercase manifest", false, []string{"key"}, "MANIFEST.JSON"}, + {"intermediate with mixed case manifest", true, []string{"pem", "fullchain", "json"}, "Manifest.Json"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + selected := fixture.ca + if test.intermediate { + selected = newIntermediateCA(t, fixture.ca) + } + keyPEM, err := certkit.MarshalPrivateKeyToPEM(selected.key) + if err != nil { + t.Fatal(err) + } + store := certstore.NewMemStore() + if err := store.HandleCertificate(fixture.ca.cert, "root.pem"); err != nil { + t.Fatal(err) + } + if err := store.HandleCertificate(selected.cert, "selected.pem"); err != nil { + t.Fatal(err) + } + if err := store.HandleKey(selected.key, []byte(keyPEM), "selected.key"); err != nil { + t.Fatal(err) + } + fixture.input.Configs = []BundleConfig{{BundleName: "managed-ca", CommonNames: []string{selected.cert.Subject.CommonName}}} + AssignBundleNames(store, fixture.input.Configs) + fixture.input.Store, fixture.input.Formats = store, test.formats + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); err != nil { + t.Fatal(err) + } + if test.manifest != "" { + dir := plan.Entries[0].OutputDirectory + if err := os.Rename(filepath.Join(dir, "manifest.json"), filepath.Join(dir, test.manifest)); err != nil { + t.Fatal(err) + } + } + refresh, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + entry := refresh.Entries[0] + if entry.ExistingLeaf == nil || entry.ExistingLeaf.Fingerprint != certkit.CertFingerprint(selected.cert) { + t.Fatalf("existing CA identity lost or confused with chain root: %+v", entry) + } + if entry.Reason != "same leaf certificate; refresh selected artifacts" { + t.Fatalf("CA refresh unexpectedly needs a replacement override: %s", entry.Reason) + } + if err := refresh.Write(context.Background()); err != nil { + t.Fatal(err) + } + + // Recognizing a managed CA must also preserve downgrade protection. + older := resignBundleLeaf(t, resignBundleLeafInput{ + Fixture: bundlePlanFixture{ca: fixture.ca, leaf: testLeaf{cert: selected.cert}}, Serial: 99, + NotBefore: selected.cert.NotBefore, NotAfter: selected.cert.NotAfter.Add(-time.Hour)}) + store = certstore.NewMemStore() + if err := store.HandleCertificate(older, "older.pem"); err != nil { + t.Fatal(err) + } + // Every selected artifact must be producible to reach replacement checks. + if err := store.HandleKey(selected.key, []byte(keyPEM), "older.key"); err != nil { + t.Fatal(err) + } + AssignBundleNames(store, fixture.input.Configs) + fixture.input.Store, fixture.input.ForceBundle = store, false + fixture.input.TrustStore = "custom" + fixture.input.CustomRoots = []*x509.Certificate{fixture.ca.cert} + if !test.intermediate { + // For root bundles, trust the candidate root itself. + fixture.input.CustomRoots = []*x509.Certificate{older} + } + downgrade, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if !errors.Is(downgrade.Validate(), ErrBundlePlanBlocked) || !strings.Contains(downgrade.Entries[0].Reason, "downgrade") { + t.Fatalf("CA expiration downgrade was not identified: %+v", downgrade.Entries) + } + }) + } +} diff --git a/internal/bundleplan_lock_test.go b/internal/bundleplan_lock_test.go new file mode 100644 index 0000000..a694c9e --- /dev/null +++ b/internal/bundleplan_lock_test.go @@ -0,0 +1,225 @@ +package internal + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sensiblebit/certkit/internal/certstore" +) + +func TestBundlePlan_RefreshLockCleanup(t *testing.T) { + // Filesystem injection must remain serial with other writer tests. + for _, test := range []struct { + name string + scenario string + manifest bool + }{ + {"nonempty lock", "nonempty lock", false}, + {"replaced lock during artifacts", "replaced lock", false}, + {"replaced lock during manifest", "replaced lock", true}, + {"moved output during artifacts", "moved output", false}, + {"moved output during manifest", "moved output", true}, + } { + t.Run(test.name, func(t *testing.T) { + fixture := newBundlePlanFixture(t) + fixture.input.Formats = []string{"key", "pem"} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + lock := filepath.Join(fixture.input.OutDir, bundleRefreshLockName) + movedOutput := fixture.input.OutDir + "-moved" + decoy := "" + originalWrite := exporterWriteFile + t.Cleanup(func() { exporterWriteFile = originalWrite }) + changed := false + exporterWriteFile = func(root *os.Root, file certstore.BundleFile) error { + if err := originalWrite(root, file); err != nil { + return err + } + if changed || (test.manifest && file.Name != bundleManifestName) || (!test.manifest && !file.Sensitive) { + return nil + } + changed = true + switch test.scenario { + case "nonempty lock": + if err := os.WriteFile(filepath.Join(lock, "keep"), []byte("external content"), 0600); err != nil { + t.Fatal(err) + } + case "replaced lock": + if err := os.Rename(lock, lock+"-original"); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(lock, 0700); err != nil { + t.Fatal(err) + } + case "moved output": + if err := os.Rename(fixture.input.OutDir, movedOutput); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(lock, 0700); err != nil { + t.Fatal(err) + } + decoyDirectory := filepath.Join(fixture.input.OutDir, filepath.Base(root.Name())) + if err := os.Mkdir(decoyDirectory, 0700); err != nil { + t.Fatal(err) + } + decoy = filepath.Join(decoyDirectory, "keep") + if err := os.WriteFile(decoy, []byte("external content"), 0600); err != nil { + t.Fatal(err) + } + } + return nil + } + err = plan.Write(context.Background()) + if err == nil { + t.Fatal("lock interference was silently accepted") + } + if _, err := os.Stat(lock); err != nil { + t.Fatalf("external lock was removed: %v", err) + } + if test.scenario == "nonempty lock" { + if !strings.Contains(err.Error(), "removing bundle refresh lock") || !strings.Contains(err.Error(), bundleRefreshLockName) { + t.Fatalf("cleanup failure lacked context: %v", err) + } + if plan.Entries[0].Status != "created" { + t.Fatalf("cleanup failure hid committed output: %+v", plan.Entries[0]) + } + if _, err := os.Stat(filepath.Join(plan.Entries[0].OutputDirectory, bundleManifestName)); err != nil { + t.Fatalf("committed manifest missing: %v", err) + } + } else { + if !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("changed lock or destination did not block write: %v", err) + } + if plan.Entries[0].Status != "blocked" || plan.Entries[0].Reason == "" { + t.Errorf("rejected bundle still reported as planned: %+v", plan.Entries[0]) + } + if !errors.Is(plan.Validate(), ErrBundlePlanBlocked) { + t.Error("rejected plan still validates") + } + if _, err := os.Stat(plan.Entries[0].OutputDirectory); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("blocked write committed output: %v", err) + } + } + if test.scenario == "moved output" { + if _, err := os.Stat(filepath.Join(movedOutput, bundleRefreshLockName)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("original lock was not released through its directory handle: %v", err) + } + children, err := os.ReadDir(movedOutput) + if err != nil || len(children) != 0 { + t.Fatalf("failed write stranded staged private artifacts: %v, %v", children, err) + } + if string(mustReadTestFile(t, decoy)) != "external content" { + t.Fatal("cleanup modified a replacement staging path") + } + } + }) + } +} + +func TestBundlePlan_LateSafetyFailurePreservesCommittedStatus(t *testing.T) { + // Filesystem injection must remain serial with other writer tests. + for _, scenario := range []string{"moved output", "replaced lock", "removed lock"} { + t.Run(scenario, func(t *testing.T) { + fixture := newBundlePlanFixture(t) + fixture.input.Formats = []string{"pem", "key"} + other := newECDSALeaf(t, fixture.ca, "z-next.example.com", nil) + if err := fixture.input.Store.HandleCertificate(other.cert, "next.pem"); err != nil { + t.Fatal(err) + } + if err := fixture.input.Store.HandleKey(other.key, other.keyPEM, "next.key"); err != nil { + t.Fatal(err) + } + fixture.input.Configs = append(fixture.input.Configs, BundleConfig{BundleName: "z-next", CommonNames: []string{"z-next.example.com"}}) + AssignBundleNames(fixture.input.Store, fixture.input.Configs) + initial, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := initial.Write(context.Background()); err != nil { + t.Fatal(err) + } + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + original, err := inspectBundleDirectory(plan.Entries[1].OutputDirectory) + if err != nil { + t.Fatal(err) + } + originalWrite := exporterWriteFile + t.Cleanup(func() { exporterWriteFile = originalWrite }) + manifests := 0 + actualOutput := fixture.input.OutDir + lock := filepath.Join(fixture.input.OutDir, bundleRefreshLockName) + exporterWriteFile = func(root *os.Root, file certstore.BundleFile) error { + if err := originalWrite(root, file); err != nil { + return err + } + if file.Name != bundleManifestName { + return nil + } + manifests++ + if manifests != 2 { + return nil + } + switch scenario { + case "moved output": + actualOutput += "-moved" + if err := os.Rename(fixture.input.OutDir, actualOutput); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(fixture.input.OutDir, 0700); err != nil { + t.Fatal(err) + } + case "replaced lock": + if err := os.Rename(lock, lock+"-original"); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(lock, 0700); err != nil { + t.Fatal(err) + } + case "removed lock": + if err := os.Remove(lock); err != nil { + t.Fatal(err) + } + } + return nil + } + err = plan.Write(context.Background()) + if !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("late safety failure was not blocked: %v", err) + } + if scenario == "removed lock" && !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing-lock error cause was lost: %v", err) + } + if plan.Entries[0].Status != "replaced" || plan.Entries[1].Status != "blocked" { + t.Fatalf("incorrect partial-write statuses: %+v", plan.Entries) + } + if !errors.Is(plan.Validate(), ErrBundlePlanBlocked) { + t.Fatal("failed plan still validates") + } + current, err := inspectBundleDirectory(filepath.Join(actualOutput, "z-next")) + if err != nil || current.digest != original.digest { + t.Fatalf("blocked replacement changed existing files: %v", err) + } + children, err := os.ReadDir(actualOutput) + if err != nil { + t.Fatal(err) + } + for _, child := range children { + if strings.HasPrefix(child.Name(), ".certkit.tmp-") || strings.HasPrefix(child.Name(), ".certkit.bak-") { + t.Fatalf("failed write stranded private artifacts: %s", child.Name()) + } + } + if err := plan.Write(context.Background()); !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("stale plan was reusable: %v", err) + } + }) + } +} diff --git a/internal/bundleplan_safety_test.go b/internal/bundleplan_safety_test.go new file mode 100644 index 0000000..8daa72b --- /dev/null +++ b/internal/bundleplan_safety_test.go @@ -0,0 +1,539 @@ +package internal + +import ( + "context" + "crypto/x509" + "encoding/json" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/sensiblebit/certkit" + "github.com/sensiblebit/certkit/internal/certstore" +) + +func TestBundlePlan_UntrustedReplacementDoesNotBlockOptionalExports(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + requirement string + force bool + }{ + {"optional", "", false}, + {"required", "required", false}, + {"selected", "selected", false}, + {"fail on skip", "fail-on-skip", false}, + {"explicit force", "", true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + trustedCA := newECDSACA(t) + healthy := newECDSALeaf(t, trustedCA, "healthy.example.com", nil) + store := fixture.input.Store + if err := store.HandleCertificate(healthy.cert, "healthy.pem"); err != nil { + t.Fatal(err) + } + if err := store.HandleKey(healthy.key, healthy.keyPEM, "healthy.key"); err != nil { + t.Fatal(err) + } + fixture.input.Configs = append(fixture.input.Configs, BundleConfig{BundleName: "healthy-tls", CommonNames: []string{"healthy.example.com"}}) + AssignBundleNames(store, fixture.input.Configs) + fixture.input.TrustStore, fixture.input.ForceBundle = "custom", test.force + fixture.input.CustomRoots = []*x509.Certificate{trustedCA.cert} + switch test.requirement { + case "required": + fixture.input.RequireBundles = []string{"service-tls"} + case "selected": + fixture.input.BundleNames = []string{"service-tls", "healthy-tls"} + case "fail-on-skip": + fixture.input.FailOnSkip = true + } + installed := resignBundleLeaf(t, resignBundleLeafInput{Fixture: fixture, Serial: 42, + NotBefore: fixture.leaf.cert.NotBefore, NotAfter: fixture.leaf.cert.NotAfter.Add(time.Hour)}) + dir := filepath.Join(fixture.input.OutDir, "service-tls") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "installed.pem") + original := certkit.CertToPEM(installed) + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + index := slices.IndexFunc(plan.Entries, func(entry BundleExportEntry) bool { return entry.BundleName == "service-tls" }) + if index < 0 { + t.Fatal("missing optional bundle decision") + } + entry := plan.Entries[index] + if test.force { + if entry.Status != "planned" || !entry.Forced || !strings.Contains(entry.Reason, "downgrade") { + t.Fatalf("missing explicit force decision: %+v", entry) + } + } else if entry.Status != "skipped" || entry.Chain.Status != "untrusted" || !strings.Contains(entry.Reason, "verification failed") { + t.Fatalf("untrusted candidate was not skipped: %+v", entry) + } + err = plan.Write(context.Background()) + healthyDir := filepath.Join(fixture.input.OutDir, "healthy-tls") + if test.requirement != "" { + if !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("required untrusted bundle must fail: %v", err) + } + if _, err := os.Stat(healthyDir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("blocked plan wrote another bundle") + } + } else { + if err != nil { + t.Fatalf("optional candidate blocked healthy output: %v", err) + } + if _, err := os.Stat(filepath.Join(healthyDir, "manifest.json")); err != nil { + t.Fatalf("healthy bundle was not produced: %v", err) + } + } + if !test.force && string(mustReadTestFile(t, path)) != original { + t.Fatal("skipped candidate changed the existing bundle") + } + }) + } +} + +func TestBundlePlan_ReservedOutputNames(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + commonName string + bundleName string + formats []string + wantError bool + }{ + {"manifest default formats", "manifest", "service-tls", nil, true}, + {"manifest json only", "manifest", "service-tls", []string{"json"}, true}, + {"manifest case variant", "MANIFEST", "service-tls", []string{"json"}, true}, + {"manifest without json", "manifest", "service-tls", []string{"pem", "key"}, false}, + {"lock fallback name", ".certkit-refresh.lock", "", nil, true}, + {"lock case variant", ".CERTKIT-REFRESH.LOCK", "", nil, true}, + {"lock after sanitization", " .certkit-refresh.lock ", "", nil, true}, + {"lock with explicit bundle name", ".certkit-refresh.lock", "service-tls", nil, false}, + {"device artifact with safe bundle name", "CON", "service-tls", []string{"pem"}, true}, + {"device artifact with extension", "COM1.example.com", "service-tls", []string{"key"}, true}, + {"NUL artifact with safe bundle name", "bad\x00name", "service-tls", []string{"pem"}, true}, + {"tab artifact with safe bundle name", "bad\tname", "service-tls", []string{"pem"}, true}, + {"newline artifact with safe bundle name", "bad\nname", "service-tls", []string{"key"}, true}, + {"delete control artifact", "bad\x7fname", "service-tls", []string{"json"}, true}, + {"Unicode control artifact", "bad\u0085name", "service-tls", []string{"pem"}, true}, + {"control in fallback directory", "bad\tname", "", []string{"pem"}, true}, + {"trailing period only in artifact prefix", "service.", "service-tls", []string{"pem"}, false}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + leaf := newECDSALeaf(t, fixture.ca, test.commonName, nil) + if err := fixture.input.Store.HandleCertificate(leaf.cert, "delivery.pem"); err != nil { + t.Fatal(err) + } + if err := fixture.input.Store.HandleKey(leaf.key, leaf.keyPEM, "delivery.key"); err != nil { + t.Fatal(err) + } + fixture.input.Configs = []BundleConfig{{BundleName: test.bundleName, CommonNames: []string{test.commonName}}} + AssignBundleNames(fixture.input.Store, fixture.input.Configs) + fixture.input.Formats = test.formats + plan, err := PlanBundleExports(context.Background(), fixture.input) + if test.wantError { + if !errors.Is(err, errBundlePlanInput) || !strings.Contains(err.Error(), "reserved") { + t.Fatalf("reserved output was accepted: %v", err) + } + if _, err := os.Stat(fixture.input.OutDir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("reserved-name failure created output") + } + return + } + if err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); err != nil { + t.Fatal(err) + } + files, err := os.ReadDir(plan.Entries[0].OutputDirectory) + if err != nil || len(files) != len(plan.Entries[0].Files) { + t.Fatalf("planned artifact was overwritten: %v", err) + } + }) + } +} + +func TestBundlePlan_RejectsCaseInsensitiveDirectoryCollisions(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + names []string + missing int + }{ + {"ASCII case", []string{"MIXED.example.com", "mixed.example.com"}, -1}, + {"Unicode case", []string{"SigmaΣ", "Sigmaς"}, -1}, + {"Unicode normalization", []string{"caf\u00e9", "cafe\u0301"}, -1}, + {"sanitized aliases", []string{"api/example.com", "api_example.com"}, -1}, + {"first selected alias has no certificate", []string{"api/example.com", "api_example.com"}, 0}, + {"last selected alias has no certificate", []string{"api/example.com", "api_example.com"}, 1}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + fixture.input.Configs = nil + fixture.input.Formats = []string{"pem"} + for i, name := range test.names { + if i != test.missing { + leaf := newECDSALeaf(t, fixture.ca, name, nil) + if err := fixture.input.Store.HandleCertificate(leaf.cert, name+".pem"); err != nil { + t.Fatal(err) + } + } + fixture.input.Configs = append(fixture.input.Configs, BundleConfig{CommonNames: []string{name}}) + } + AssignBundleNames(fixture.input.Store, fixture.input.Configs) + if _, err := PlanBundleExports(context.Background(), fixture.input); !errors.Is(err, errExportBundleFolderCollision) { + t.Fatalf("colliding bundle directories were accepted: %v", err) + } + if _, err := os.Stat(fixture.input.OutDir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("directory collision wrote output") + } + }) + } +} + +func TestBundlePlan_ComponentLengthLimits(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + commonName string + bundleName string + wantError bool + }{ + {"oversized artifact", strings.Repeat("a", 300), "service-tls", true}, + {"artifact just over limit", strings.Repeat("a", 252), "service-tls", true}, + {"artifact at limit", strings.Repeat("a", 251), "service-tls", false}, + {"multibyte artifact over limit", strings.Repeat("é", 126), "service-tls", true}, + {"multibyte artifact within limit", strings.Repeat("é", 125), "service-tls", false}, + {"derived directory over limit", strings.Repeat("a", 256), "", true}, + {"long derived directory remains writable", strings.Repeat("a", 251), "", false}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + leaf := newECDSALeaf(t, fixture.ca, test.commonName, nil) + if err := fixture.input.Store.HandleCertificate(leaf.cert, "delivery.pem"); err != nil { + t.Fatal(err) + } + fixture.input.Configs = append(fixture.input.Configs, BundleConfig{BundleName: test.bundleName, CommonNames: []string{test.commonName}}) + // Keep the valid bundle first so invalid names cannot cause partial writes. + fixture.input.Configs[0].BundleName = "a-valid" + AssignBundleNames(fixture.input.Store, fixture.input.Configs) + fixture.input.Formats = []string{"pem"} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if test.wantError { + if !errors.Is(err, errBundlePlanInput) || !strings.Contains(err.Error(), "255-byte") { + t.Fatalf("overlong component accepted: %v", err) + } + if _, err := os.Stat(fixture.input.OutDir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("invalid plan wrote output") + } + return + } + if err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); err != nil { + t.Fatalf("accepted component was not writable: %v", err) + } + for _, entry := range plan.Entries { + if entry.Status != "created" { + t.Fatalf("missing bundle: %+v", entry) + } + } + }) + } +} + +func TestBundlePlan_DerivedNameCollisionWithinRule(t *testing.T) { + t.Parallel() + for _, bundleName := range []string{"", "service-tls"} { + name := "derived names must be distinct" + if bundleName != "" { + name = "explicit name groups common names" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + commonNames := []string{"*.example.com", "_.example.com"} + for _, commonName := range commonNames { + leaf := newECDSALeaf(t, fixture.ca, commonName, nil) + if err := fixture.input.Store.HandleCertificate(leaf.cert, "delivery.pem"); err != nil { + t.Fatal(err) + } + } + fixture.input.Configs = []BundleConfig{{BundleName: bundleName, CommonNames: commonNames}} + fixture.input.Formats = []string{"pem"} + AssignBundleNames(fixture.input.Store, fixture.input.Configs) + plan, err := PlanBundleExports(context.Background(), fixture.input) + if bundleName == "" { + if !errors.Is(err, errBundlePlanInput) { + t.Fatalf("derived collision was accepted: %v", err) + } + if _, err := os.Stat(fixture.input.OutDir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("derived collision created output") + } + return + } + if err != nil { + t.Fatal(err) + } + if len(plan.Entries) != 1 || plan.Entries[0].CandidateCount != 2 { + t.Fatalf("explicit grouping was lost: %+v", plan.Entries) + } + if err := plan.Write(context.Background()); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestBundlePlan_PreservesUnselectedDirectoryAliases(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + selected string + unselected string + existing string + manifest string + manifestFile string + }{ + {"existing case variant", "mixed.example.com", "", "MIXED.example.com", "", ""}, + {"existing Unicode case variant", "SigmaΣ", "", "Sigmaς", "", ""}, + {"existing Unicode normalization variant", "caf\u00e9", "", "cafe\u0301", "", ""}, + {"unselected Unicode normalization alias", "caf\u00e9", "cafe\u0301", "cafe\u0301", "", ""}, + {"unselected sanitized alias", "api/example.com", "api_example.com", "api_example.com", "", ""}, + {"unselected whitespace alias", " mixed.example.com ", "mixed.example.com", "mixed.example.com", "", ""}, + {"manifest retains removed alias", "api/example.com", "", "api_example.com", "api_example.com", "manifest.json"}, + {"uppercase manifest retains removed alias", "api/example.com", "", "api_example.com", "api_example.com", "MANIFEST.JSON"}, + {"mixed case manifest retains removed alias", "api/example.com", "", "api_example.com", "api_example.com", "Manifest.Json"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + leaf := newECDSALeaf(t, fixture.ca, test.selected, nil) + if err := fixture.input.Store.HandleCertificate(leaf.cert, "delivery.pem"); err != nil { + t.Fatal(err) + } + fixture.input.Configs = []BundleConfig{{CommonNames: []string{test.selected}}} + if test.unselected != "" { + fixture.input.Configs = append(fixture.input.Configs, BundleConfig{CommonNames: []string{test.unselected}}) + } + AssignBundleNames(fixture.input.Store, fixture.input.Configs) + fixture.input.BundleNames = []string{test.selected} + fixture.input.Formats = []string{"pem"} + dir := filepath.Join(fixture.input.OutDir, test.existing) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "installed.pem") + original := []byte(certkit.CertToPEM(leaf.cert)) + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + if test.manifest != "" { + data, err := json.Marshal(BundleExportEntry{BundleName: test.manifest}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, test.manifestFile), data, 0600); err != nil { + t.Fatal(err) + } + } + if _, err := PlanBundleExports(context.Background(), fixture.input); !errors.Is(err, errExportBundleFolderCollision) { + t.Fatalf("unselected alias was accepted despite force: %v", err) + } + if string(mustReadTestFile(t, path)) != string(original) { + t.Fatal("unselected bundle was changed") + } + }) + } +} + +func TestBundlePlan_RechecksDirectoryNamesBeforeWrite(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); err != nil { + t.Fatal(err) + } + plan, err = PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + originalDir := filepath.Join(fixture.input.OutDir, "service-tls") + renamedDir := filepath.Join(fixture.input.OutDir, "SERVICE-TLS") + if err := os.Rename(originalDir, renamedDir); err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(renamedDir, "manifest.json") + original := mustReadTestFile(t, manifestPath) + if err := plan.Write(context.Background()); !errors.Is(err, errExportBundleFolderCollision) { + t.Fatalf("renamed unselected directory was accepted: %v", err) + } + if plan.Entries[0].Status != "blocked" || !errors.Is(plan.Validate(), ErrBundlePlanBlocked) { + t.Fatalf("stale scope did not invalidate the plan: %+v", plan.Entries[0]) + } + if string(mustReadTestFile(t, manifestPath)) != string(original) { + t.Fatal("renamed bundle was changed") + } + children, err := os.ReadDir(fixture.input.OutDir) + if err != nil || len(children) != 1 || children[0].Name() != "SERVICE-TLS" { + t.Fatalf("blocked write changed directory names: %v, %v", children, err) + } + if err := os.Rename(renamedDir, originalDir); err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("stale plan was reusable after restoring directory names: %v", err) + } +} + +func TestBundlePlan_LateScopeConflictPreservesCommittedStatus(t *testing.T) { + // Filesystem injection must remain serial with other writer tests. + fixture := newBundlePlanFixture(t) + fixture.input.Formats = []string{"pem"} + other := newECDSALeaf(t, fixture.ca, "z-next.example.com", nil) + if err := fixture.input.Store.HandleCertificate(other.cert, "next.pem"); err != nil { + t.Fatal(err) + } + fixture.input.Configs = append(fixture.input.Configs, BundleConfig{BundleName: "z-next", CommonNames: []string{"z-next.example.com"}}) + AssignBundleNames(fixture.input.Store, fixture.input.Configs) + initial, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := initial.Write(context.Background()); err != nil { + t.Fatal(err) + } + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + directory := filepath.Join(fixture.input.OutDir, "z-next") + renamed := filepath.Join(fixture.input.OutDir, "Z-NEXT") + original := mustReadTestFile(t, filepath.Join(directory, bundleManifestName)) + originalWrite := exporterWriteFile + t.Cleanup(func() { exporterWriteFile = originalWrite }) + exporterWriteFile = func(root *os.Root, file certstore.BundleFile) error { + if err := originalWrite(root, file); err != nil { + return err + } + if file.Name == "z-next.example.com.pem" { + if err := os.Rename(directory, renamed); err != nil { + t.Fatal(err) + } + } + return nil + } + if err := plan.Write(context.Background()); !errors.Is(err, ErrBundlePlanBlocked) || !errors.Is(err, errExportBundleFolderCollision) { + t.Fatalf("late scope conflict did not invalidate the write: %v", err) + } + if plan.Entries[0].Status != "replaced" || plan.Entries[1].Status != "blocked" { + t.Fatalf("incorrect partial-write statuses: %+v", plan.Entries) + } + if string(mustReadTestFile(t, filepath.Join(renamed, bundleManifestName))) != string(original) { + t.Fatal("renamed bundle was overwritten") + } +} + +func TestBundlePlan_RejectsUnselectedAliasesBeforeFirstExport(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + mkdir bool + }{ + {"missing output directory", false}, + {"empty output directory", true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + fixture.input.Configs = append(fixture.input.Configs, BundleConfig{CommonNames: []string{" service-tls "}}) + fixture.input.BundleNames = []string{"service-tls"} + fixture.input.Formats = []string{"pem"} + if test.mkdir { + if err := os.MkdirAll(fixture.input.OutDir, 0700); err != nil { + t.Fatal(err) + } + } + if _, err := PlanBundleExports(context.Background(), fixture.input); !errors.Is(err, errExportBundleFolderCollision) { + t.Fatalf("first export claimed an unselected alias: %v", err) + } + children, err := os.ReadDir(fixture.input.OutDir) + if test.mkdir && (err != nil || len(children) != 0) { + t.Fatalf("blocked plan changed the empty output directory: %v, %v", children, err) + } + if !test.mkdir && !errors.Is(err, os.ErrNotExist) { + t.Fatalf("blocked plan created an output directory: %v", err) + } + }) + } +} + +func TestBundlePlan_RejectsConflictingManifestIdentities(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + fixture.input.Formats = []string{"pem"} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); err != nil { + t.Fatal(err) + } + dir := plan.Entries[0].OutputDirectory + uppercase := filepath.Join(dir, "MANIFEST.JSON") + if _, err := os.Stat(uppercase); err == nil { + t.Skip("filesystem cannot store both manifest filename case variants") + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + if err := os.WriteFile(uppercase, []byte(`{"bundle_name":"another-bundle"}`), 0600); err != nil { + t.Fatal(err) + } + if _, err := PlanBundleExports(context.Background(), fixture.input); !errors.Is(err, errBundleInspection) { + t.Fatalf("conflicting manifest identities were accepted despite force: %v", err) + } +} + +func TestBundlePlan_RejectsWindowsReservedDirectories(t *testing.T) { + t.Parallel() + for _, name := range []string{"service.", "service..", "service. ", "CON", "nul.example.com", "Aux", "COM1", "lpt9.txt", "COM¹", "CONIN$", "conout$"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + leaf := newECDSALeaf(t, fixture.ca, name, nil) + if err := fixture.input.Store.HandleCertificate(leaf.cert, "delivery.pem"); err != nil { + t.Fatal(err) + } + fixture.input.Configs = append(fixture.input.Configs, BundleConfig{CommonNames: []string{name}}) + AssignBundleNames(fixture.input.Store, fixture.input.Configs) + fixture.input.Formats = []string{"pem"} + if _, err := PlanBundleExports(context.Background(), fixture.input); !errors.Is(err, errBundlePlanInput) { + t.Fatalf("windows-reserved directory was accepted: %v", err) + } + if _, err := os.Stat(fixture.input.OutDir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("invalid directory name wrote output") + } + }) + } +} diff --git a/internal/bundleplan_test.go b/internal/bundleplan_test.go new file mode 100644 index 0000000..84b453b --- /dev/null +++ b/internal/bundleplan_test.go @@ -0,0 +1,621 @@ +package internal + +import ( + "context" + "crypto/rand" + "crypto/x509" + "encoding/json" + "errors" + "math/big" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/sensiblebit/certkit" + "github.com/sensiblebit/certkit/internal/certstore" +) + +type bundlePlanFixture struct { + input BundlePlanInput + ca testCA + leaf testLeaf +} + +func newBundlePlanFixture(t *testing.T) bundlePlanFixture { + t.Helper() + ca := newECDSACA(t) + leaf := newECDSALeaf(t, ca, "service.example.com", []string{"service.example.com"}) + store := certstore.NewMemStore() + if err := store.HandleCertificate(ca.cert, "root.pem"); err != nil { + t.Fatal(err) + } + if err := store.HandleCertificate(leaf.cert, "vendor.zip!leaf.pem"); err != nil { + t.Fatal(err) + } + if err := store.HandleKey(leaf.key, leaf.keyPEM, "vendor.zip!key.pem"); err != nil { + t.Fatal(err) + } + configs := []BundleConfig{{BundleName: "service-tls", CommonNames: []string{"service.example.com"}}} + AssignBundleNames(store, configs) + return bundlePlanFixture{input: BundlePlanInput{ExportBundlesInput: ExportBundlesInput{ + Configs: configs, OutDir: filepath.Join(t.TempDir(), "bundles"), Store: store, ForceBundle: true, + }, ConfigPath: "bundles.yaml"}, ca: ca, leaf: leaf} +} + +type resignBundleLeafInput struct { + Fixture bundlePlanFixture + NotBefore time.Time + NotAfter time.Time + Serial int64 +} + +func resignBundleLeaf(t *testing.T, input resignBundleLeafInput) *x509.Certificate { + t.Helper() + template := *input.Fixture.leaf.cert + template.SerialNumber = big.NewInt(input.Serial) + template.NotBefore, template.NotAfter = input.NotBefore, input.NotAfter + der, err := x509.CreateCertificate(rand.Reader, &template, input.Fixture.ca.cert, template.PublicKey, input.Fixture.ca.key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return cert +} + +func TestBundlePlan_ScopedPreviewAndWrite(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + other := newECDSALeaf(t, fixture.ca, "unrelated.example.com", nil) + store := fixture.input.Store + if err := store.HandleCertificate(other.cert, "stale.pem"); err != nil { + t.Fatal(err) + } + if err := store.HandleKey(other.key, other.keyPEM, "stale.key"); err != nil { + t.Fatal(err) + } + fixture.input.Configs = append(fixture.input.Configs, BundleConfig{BundleName: "unrelated", CommonNames: []string{"unrelated.example.com"}}) + AssignBundleNames(store, fixture.input.Configs) + fixture.input.BundleNames = []string{"service-tls", "service-tls"} + fixture.input.Formats = []string{"pem", "key", "json"} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := plan.Validate(); err != nil { + t.Fatal(err) + } + if len(plan.Entries) != 1 { + t.Fatalf("entries = %d, want only selected bundle", len(plan.Entries)) + } + entry := plan.Entries[0] + if entry.Status != "planned" { + t.Fatalf("status = %s", entry.Status) + } + if entry.Leaf.Source != "vendor.zip!leaf.pem" || entry.KeySource != "vendor.zip!key.pem" { + t.Fatalf("wrong provenance: %+v", entry) + } + if entry.Rule.ConfigPath != "bundles.yaml" || entry.Rule.Index != 1 { + t.Fatalf("wrong rule: %+v", entry.Rule) + } + if _, err := os.Stat(fixture.input.OutDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("preview created output directory: %v", err) + } + if err := plan.Write(context.Background()); err != nil { + t.Fatal(err) + } + if plan.Entries[0].Status != "created" { + t.Fatalf("status = %s", plan.Entries[0].Status) + } + entries, err := os.ReadDir(fixture.input.OutDir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != "service-tls" { + t.Fatalf("unscoped output: %v", entries) + } + files, err := os.ReadDir(entry.OutputDirectory) + if err != nil { + t.Fatal(err) + } + var names []string + for _, file := range files { + names = append(names, file.Name()) + } + want := []string{"manifest.json", "service.example.com.json", "service.example.com.key", "service.example.com.pem"} + if !slices.Equal(names, want) { + t.Fatalf("files = %v, want %v", names, want) + } + var manifest BundleExportEntry + if err := json.Unmarshal(mustReadTestFile(t, filepath.Join(entry.OutputDirectory, "manifest.json")), &manifest); err != nil { + t.Fatal(err) + } + if manifest.Leaf.Fingerprint != certkit.CertFingerprint(fixture.leaf.cert) { + t.Fatal("manifest identifies wrong certificate") + } + if manifest.Status != "created" { + t.Fatalf("manifest status = %s", manifest.Status) + } +} + +func TestBundlePlan_RequiredBundlesRespectScope(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + selected []string + required []string + wantNames []string + wantError string + }{ + {"required outside explicit scope", []string{"service-tls"}, []string{"unrelated"}, nil, "outside the selected scope"}, + {"required within explicit scope", []string{"service-tls"}, []string{"service-tls"}, []string{"service-tls"}, ""}, + {"required without explicit scope", nil, []string{"unrelated"}, []string{"service-tls", "unrelated"}, ""}, + {"unknown required rule", []string{"service-tls"}, []string{"unknown"}, nil, "has no configuration rule"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + other := newECDSALeaf(t, fixture.ca, "unrelated.example.com", nil) + if err := fixture.input.Store.HandleCertificate(other.cert, "other.pem"); err != nil { + t.Fatal(err) + } + fixture.input.Configs = append(fixture.input.Configs, BundleConfig{BundleName: "unrelated", CommonNames: []string{"unrelated.example.com"}}) + AssignBundleNames(fixture.input.Store, fixture.input.Configs) + fixture.input.BundleNames, fixture.input.RequireBundles = test.selected, test.required + fixture.input.Formats = []string{"pem"} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if test.wantError != "" { + if !errors.Is(err, errBundlePlanInput) || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("scope error = %v, want %s", err, test.wantError) + } + if _, err := os.Stat(fixture.input.OutDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("invalid scope wrote output: %v", err) + } + return + } + if err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); err != nil { + t.Fatal(err) + } + directories, err := os.ReadDir(fixture.input.OutDir) + if err != nil { + t.Fatal(err) + } + var names []string + for _, directory := range directories { + names = append(names, directory.Name()) + } + if !slices.Equal(names, test.wantNames) { + t.Fatalf("written directories = %v, want %v", names, test.wantNames) + } + }) + } +} + +func TestBundlePlan_ReplacementProtection(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + expiryDelta time.Duration + ambiguous bool + force bool + wantBlocked bool + wantReason string + }{ + {"expiration downgrade", 24 * time.Hour, false, false, true, "downgrade"}, + {"equal expiration conflict", 0, false, false, true, "same expiration"}, + {"malformed existing leaf", 0, true, false, true, `"previous.pem" cannot be parsed`}, + {"explicit downgrade override", 24 * time.Hour, false, true, false, "downgrade"}, + {"explicit conflict override", 0, false, true, false, "same expiration"}, + {"explicit ambiguous override", 0, true, true, false, `"previous.pem" cannot be parsed`}, + {"newer expiration", -24 * time.Hour, false, true, false, "expires later"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + dir := filepath.Join(fixture.input.OutDir, "service-tls") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + original := []byte("unrecognized old contents") + if !test.ambiguous { + old := resignBundleLeaf(t, resignBundleLeafInput{Fixture: fixture, Serial: 42, + NotBefore: fixture.leaf.cert.NotBefore, NotAfter: fixture.leaf.cert.NotAfter.Add(test.expiryDelta)}) + original = []byte(certkit.CertToPEM(old)) + } + path := filepath.Join(dir, "previous.pem") + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + fixture.input.ForceBundle = test.force + fixture.input.TrustStore = "custom" + fixture.input.CustomRoots = []*x509.Certificate{fixture.ca.cert} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(plan.Entries[0].Reason, test.wantReason) { + t.Fatalf("reason = %s", plan.Entries[0].Reason) + } + err = plan.Write(context.Background()) + if test.wantBlocked { + if !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("write error = %v", err) + } + if string(mustReadTestFile(t, path)) != string(original) { + t.Fatal("blocked plan changed existing bundle") + } + } else { + if err != nil { + t.Fatal(err) + } + if plan.Entries[0].Status != "replaced" { + t.Fatalf("status = %s", plan.Entries[0].Status) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0700 { + t.Fatal("replacement changed directory permissions") + } + } + }) + } +} + +func TestBundlePlan_ManifestProtectsKeyOnlyExport(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + fixture.input.Formats = []string{"key"} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); err != nil { + t.Fatal(err) + } + second, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if second.Entries[0].ExistingLeaf == nil || second.Entries[0].ExistingLeaf.Fingerprint != second.Entries[0].Leaf.Fingerprint { + t.Fatal("manifest did not preserve leaf identity for key-only output") + } + if second.Entries[0].Reason != "same leaf certificate; refresh selected artifacts" { + t.Fatal("same leaf should not require a replacement override") + } +} + +func TestBundlePlan_ChangedAfterPreview(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); err != nil { + t.Fatal(err) + } + second, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(plan.Entries[0].OutputDirectory, "service.example.com.key") + changed := []byte("changed after planning") + if err := os.WriteFile(path, changed, 0600); err != nil { + t.Fatal(err) + } + if err := second.Write(context.Background()); !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("stale plan error = %v", err) + } + if string(mustReadTestFile(t, path)) != string(changed) { + t.Fatal("stale plan overwrote changed file") + } +} + +func TestBundlePlan_RequiredAndSkipped(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + missing bool + required bool + scoped bool + failOnSkip bool + force bool + wantBlocked bool + }{ + {"missing optional bundle", true, false, false, false, true, false}, + {"missing required bundle", true, true, false, false, true, true}, + {"missing scoped bundle", true, false, true, false, true, true}, + {"fail on skip", true, false, false, true, true, true}, + {"untrusted optional bundle", false, false, false, false, false, false}, + {"untrusted required bundle", false, true, false, false, false, true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + if test.missing { + fixture.input.Store = certstore.NewMemStore() + } + if test.required { + fixture.input.RequireBundles = []string{"service-tls"} + } + if test.scoped { + fixture.input.BundleNames = []string{"service-tls"} + } + fixture.input.FailOnSkip, fixture.input.ForceBundle = test.failOnSkip, test.force + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if len(plan.Entries) != 1 || plan.Entries[0].Status != "skipped" || plan.Entries[0].Reason == "" { + t.Fatalf("missing visible skip reason: %+v", plan.Entries) + } + if errors.Is(plan.Validate(), ErrBundlePlanBlocked) != test.wantBlocked { + t.Fatalf("validation = %v", plan.Validate()) + } + }) + } +} + +func TestBundlePlan_SelectionIndependentOfInputOrder(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + now := time.Now().UTC().Truncate(time.Second) + first := resignBundleLeaf(t, resignBundleLeafInput{Fixture: fixture, Serial: 1, NotBefore: now.Add(-4 * time.Hour), NotAfter: now.Add(24 * time.Hour)}) + second := resignBundleLeaf(t, resignBundleLeafInput{Fixture: fixture, Serial: 2, NotBefore: now.Add(-2 * time.Hour), NotAfter: first.NotAfter}) + third := resignBundleLeaf(t, resignBundleLeafInput{Fixture: fixture, Serial: 3, NotBefore: second.NotBefore, NotAfter: first.NotAfter}) + fourth := resignBundleLeaf(t, resignBundleLeafInput{Fixture: fixture, Serial: 4, NotBefore: second.NotBefore, NotAfter: now.Add(12 * time.Hour)}) + want := min(certkit.CertFingerprint(second), certkit.CertFingerprint(third)) + fixture.input.FailOnSkip = true + for _, certs := range [][]*x509.Certificate{{first, second, third, fourth}, {fourth, third, first, second}, {second, third, fourth, first}} { + store := certstore.NewMemStore() + for _, cert := range certs { + if err := store.HandleCertificate(cert, "delivery.pem"); err != nil { + t.Fatal(err) + } + } + if err := store.HandleKey(fixture.leaf.key, fixture.leaf.keyPEM, "delivery.key"); err != nil { + t.Fatal(err) + } + AssignBundleNames(store, fixture.input.Configs) + fixture.input.Store = store + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if plan.Entries[0].Leaf.Fingerprint != want { + t.Fatalf("selected %s, want %s", plan.Entries[0].Leaf.Fingerprint, want) + } + if plan.Entries[0].CandidateCount != 4 || !strings.Contains(plan.Entries[0].SelectionReason, "not_before") { + t.Fatal("selection was not explained") + } + decisions := plan.Entries[0].SkippedCandidates + if len(decisions) != 3 { + t.Fatalf("unselected candidates = %d, want 3", len(decisions)) + } + if decisions[0].Leaf.Fingerprint == want || decisions[0].Reason != "higher SHA-256 fingerprint than the selected candidate" { + t.Fatalf("incorrect fingerprint decision: %+v", decisions[0]) + } + if decisions[1].Leaf.Fingerprint != certkit.CertFingerprint(first) || decisions[1].Reason != "earlier issuance time than the selected candidate" { + t.Fatalf("incorrect issuance decision: %+v", decisions[1]) + } + if decisions[2].Leaf.Fingerprint != certkit.CertFingerprint(fourth) || decisions[2].Reason != "earlier expiration than the selected candidate" { + t.Fatalf("incorrect expiration decision: %+v", decisions[2]) + } + for _, decision := range decisions { + if decision.Leaf.Source != "delivery.pem" || decision.KeySource != "delivery.key" { + t.Fatalf("missing unselected candidate provenance: %+v", decision) + } + } + if err := plan.Validate(); err != nil { + t.Fatalf("unselected alternatives must not fail --fail-on-skip: %v", err) + } + } + fixture.input.Duplicates = true + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if len(plan.Entries) != 4 { + t.Fatalf("--duplicates planned %d candidates, want 4", len(plan.Entries)) + } + for _, entry := range plan.Entries { + if entry.Status != "planned" || len(entry.SkippedCandidates) != 0 { + t.Fatalf("duplicate incorrectly reported as skipped: %+v", entry) + } + } +} + +func TestBundlePlan_InvalidConfiguration(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + configure func(*BundlePlanInput) + }{ + {"no rules", func(in *BundlePlanInput) { in.Configs = nil }}, + {"empty rule", func(in *BundlePlanInput) { in.Configs[0].CommonNames = nil }}, + {"duplicate rule", func(in *BundlePlanInput) { in.Configs = append(in.Configs, in.Configs[0]) }}, + {"unknown scope", func(in *BundlePlanInput) { in.BundleNames = []string{"unknown"} }}, + {"unknown artifact", func(in *BundlePlanInput) { in.Formats = []string{"unknown"} }}, + {"invalid Kubernetes name", func(in *BundlePlanInput) { + in.Configs[0].BundleName = "invalid_name" + in.Formats = []string{"k8s"} + }}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + test.configure(&fixture.input) + if _, err := PlanBundleExports(context.Background(), fixture.input); err == nil { + t.Fatal("invalid plan accepted") + } + if _, err := os.Stat(fixture.input.OutDir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("invalid plan created output") + } + }) + } +} + +func TestBundlePlan_ExpiryRequiresSeparateOptIn(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + force bool + allowExpired bool + wantStatus string + wantReason string + }{ + {"expired leaf is skipped", false, false, "skipped", "--allow-expired"}, + {"force still requires expiry opt in", true, false, "skipped", "--allow-expired"}, + {"expiry opt in still requires trust", false, true, "skipped", "verification failed"}, + {"both overrides permit export", true, true, "planned", "new bundle"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + now := time.Now() + expired := resignBundleLeaf(t, resignBundleLeafInput{Fixture: fixture, Serial: 10, + NotBefore: now.Add(-30 * time.Minute), NotAfter: now.Add(-time.Minute)}) + store := certstore.NewMemStore() + if err := store.HandleCertificate(expired, "expired.pem"); err != nil { + t.Fatal(err) + } + AssignBundleNames(store, fixture.input.Configs) + fixture.input.Store = store + fixture.input.Formats = []string{"pem", "json"} + fixture.input.ForceBundle, fixture.input.AllowExpired = test.force, test.allowExpired + fixture.input.RequireBundles = []string{"service-tls"} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + entry := plan.Entries[0] + if entry.Status != test.wantStatus || !strings.Contains(entry.Reason, test.wantReason) { + t.Fatalf("unexpected expiry decision: %+v", entry) + } + if errors.Is(plan.Validate(), ErrBundlePlanBlocked) != (test.wantStatus == "skipped") { + t.Fatalf("unexpected plan validation: %v", plan.Validate()) + } + if test.wantStatus == "planned" && (!entry.Forced || entry.Chain.Status != "verification_disabled") { + t.Fatalf("missing force provenance: %+v", entry) + } + }) + } +} + +func TestBundlePlan_PublicArtifactsWithoutKey(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + store := certstore.NewMemStore() + if err := store.HandleCertificate(fixture.leaf.cert, "delivery.pem"); err != nil { + t.Fatal(err) + } + AssignBundleNames(store, fixture.input.Configs) + fixture.input.Store = store + for _, test := range []struct { + name string + formats []string + status string + }{ + {"public only", []string{"pem", "json"}, "planned"}, + {"key required", []string{"key"}, "skipped"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + input := fixture.input + input.Formats = test.formats + plan, err := PlanBundleExports(context.Background(), input) + if err != nil { + t.Fatal(err) + } + if plan.Entries[0].Status != test.status { + t.Fatalf("status = %s, want %s", plan.Entries[0].Status, test.status) + } + if test.status == "skipped" && !strings.Contains(plan.Entries[0].Reason, "private key") { + t.Fatal("missing reason for skipped key artifact") + } + }) + } +} + +func TestBundlePlan_ProtectsUnselectedDirectories(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + fixture.input.Configs = append(fixture.input.Configs, BundleConfig{BundleName: "unrelated", CommonNames: []string{"unrelated.example.com"}}) + fixture.input.BundleNames = []string{"service-tls"} + unrelated := filepath.Join(fixture.input.OutDir, "unrelated") + if err := os.MkdirAll(unrelated, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(unrelated, "keep-this-file") + if err := os.WriteFile(path, []byte("unrelated data"), 0600); err != nil { + t.Fatal(err) + } + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); err != nil { + t.Fatal(err) + } + if string(mustReadTestFile(t, path)) != "unrelated data" { + t.Fatal("unselected directory was changed") + } +} + +func TestBundlePlan_RejectsSymlinkedBundle(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + if err := os.MkdirAll(fixture.input.OutDir, 0700); err != nil { + t.Fatal(err) + } + target := t.TempDir() + createSymlinkOrSkip(t, target, filepath.Join(fixture.input.OutDir, "service-tls")) + if _, err := PlanBundleExports(context.Background(), fixture.input); err == nil { + t.Fatal("symlinked bundle accepted even with force") + } + entries, err := os.ReadDir(target) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatal("symlink target modified") + } +} + +func TestBundlePlan_RejectsConcurrentWriter(t *testing.T) { + t.Parallel() + for _, name := range []string{".certkit-refresh.lock", ".CERTKIT-REFRESH.LOCK", ".CertKit-Refresh.Lock"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + lock := filepath.Join(fixture.input.OutDir, name) + if err := os.MkdirAll(lock, 0700); err != nil { + t.Fatal(err) + } + if err := plan.Write(context.Background()); !errors.Is(err, os.ErrExist) { + t.Fatalf("lock error = %v", err) + } + if _, err := os.Stat(plan.Entries[0].OutputDirectory); !errors.Is(err, os.ErrNotExist) { + t.Fatal("competing writer created output") + } + children, err := os.ReadDir(fixture.input.OutDir) + if err != nil || len(children) != 1 || children[0].Name() != name { + t.Fatalf("existing lock was changed or bypassed: %v, %v", children, err) + } + }) + } +} diff --git a/internal/bundleplan_validity_test.go b/internal/bundleplan_validity_test.go new file mode 100644 index 0000000..4ad2bae --- /dev/null +++ b/internal/bundleplan_validity_test.go @@ -0,0 +1,359 @@ +package internal + +import ( + "context" + "crypto/rand" + "crypto/x509" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sensiblebit/certkit/internal/certstore" +) + +func TestBundlePlan_RechecksValidityAtWriteTime(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + afterCheck bool + allowExpired bool + future bool + }{ + {"expired while reviewing", false, false, false}, + {"expired after write preflight", true, false, false}, + {"expired explicitly allowed", false, true, false}, + {"clock moves before not before", false, true, true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + fixture.input.Formats = []string{"pem"} + fixture.input.AllowExpired = test.allowExpired + initial, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := initial.Write(context.Background()); err != nil { + t.Fatal(err) + } + manifest := filepath.Join(initial.Entries[0].OutputDirectory, "manifest.json") + original := mustReadTestFile(t, manifest) + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + writeTime := fixture.leaf.cert.NotAfter.Add(time.Second) + if test.future { + writeTime = fixture.leaf.cert.NotBefore.Add(-time.Second) + } + calls := 0 + plan.now = func() time.Time { + calls++ + if test.afterCheck && calls == 1 { + return time.Now() + } + return writeTime + } + err = plan.Write(context.Background()) + if test.allowExpired && !test.future { + if err != nil || plan.Entries[0].Status != "replaced" { + t.Fatalf("explicitly allowed expired candidate failed: %v", err) + } + return + } + if !errors.Is(err, ErrBundlePlanBlocked) || plan.Entries[0].Status != "blocked" { + t.Fatalf("stale validity did not block writing: %v", err) + } + if test.future && !strings.Contains(plan.Entries[0].Reason, "not yet valid") { + t.Fatalf("unexpected future-certificate decision: %s", plan.Entries[0].Reason) + } + if string(mustReadTestFile(t, manifest)) != string(original) { + t.Fatal("invalid-at-write candidate replaced the existing bundle") + } + }) + } +} + +func TestBundlePlan_RechecksPlannedChainAtWriteTime(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + intermediate bool + afterCheck bool + allowExpired bool + force bool + expiredLeaf bool + }{ + {"root expires", false, false, false, false, false}, + {"intermediate expires", true, false, false, false, false}, + {"intermediate expires after preflight", true, true, false, false, false}, + {"allow expired does not bypass current chain trust", true, false, true, false, false}, + {"explicit force disables verification", true, false, false, true, false}, + {"expired leaf uses historical verification", true, false, true, false, true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + issuer := fixture.ca + if test.intermediate { + issuer = newECDSACA(t) + } + template := *issuer.cert + template.NotAfter = time.Now().Add(time.Hour) + if test.intermediate { + template.Subject.CommonName = "Short-lived Intermediate" + template.RawSubject = nil + template.SubjectKeyId = []byte{9, 8, 7, 6} + } + der, err := x509.CreateCertificate(rand.Reader, &template, fixture.ca.cert, issuer.cert.PublicKey, fixture.ca.key) + if err != nil { + t.Fatal(err) + } + issuer.cert, err = x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + root := fixture.ca.cert + if !test.intermediate { + root = issuer.cert + } + leaf := newECDSALeaf(t, issuer, "service.example.com", nil) + fixture.input.Store = certstore.NewMemStore() + for _, certificate := range []*x509.Certificate{root, issuer.cert, leaf.cert} { + if err := fixture.input.Store.HandleCertificate(certificate, "chain.pem"); err != nil { + t.Fatal(err) + } + } + AssignBundleNames(fixture.input.Store, fixture.input.Configs) + fixture.input.Formats = []string{"pem", "fullchain"} + fixture.input.RequireBundles = []string{"service-tls"} + fixture.input.TrustStore, fixture.input.CustomRoots = "custom", []*x509.Certificate{root} + fixture.input.ForceBundle, fixture.input.AllowExpired = test.force, test.allowExpired + initial, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := initial.Write(context.Background()); err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(initial.Entries[0].OutputDirectory, "manifest.json") + original := mustReadTestFile(t, manifestPath) + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + writeTime := issuer.cert.NotAfter.Add(time.Second) + if test.expiredLeaf { + writeTime = leaf.cert.NotAfter.Add(time.Second) + } + calls := 0 + plan.now = func() time.Time { + calls++ + if test.afterCheck && calls == 1 { + return time.Now() + } + return writeTime + } + err = plan.Write(context.Background()) + if test.force || test.expiredLeaf { + if err != nil || plan.Entries[0].Status != "replaced" { + t.Fatalf("explicit policy rejected the write: %v", err) + } + var manifest BundleExportEntry + if err := json.Unmarshal(mustReadTestFile(t, manifestPath), &manifest); err != nil { + t.Fatal(err) + } + if test.expiredLeaf && !strings.Contains(strings.Join(manifest.Chain.Warnings, "\n"), "expired leaf: chain verified at ") { + t.Fatal("missing write-time historical verification warning") + } + if test.force && manifest.Chain.Status != "verification_disabled" { + t.Fatal("force incorrectly reports a verified chain") + } + return + } + if !errors.Is(err, ErrBundlePlanBlocked) { + t.Fatalf("expired chain did not block writing: %v", err) + } + if plan.Entries[0].Status != "blocked" || plan.Entries[0].Chain.Status != "untrusted" { + t.Fatalf("stale chain status: %+v", plan.Entries[0]) + } + if string(mustReadTestFile(t, manifestPath)) != string(original) { + t.Fatal("expired chain replaced the existing bundle") + } + }) + } +} + +func TestBundlePlan_ReportsCommittedReplacementAfterCleanupFailure(t *testing.T) { + // These subtests inject filesystem failures and must not run in parallel. + for _, test := range []struct { + name string + committed bool + }{ + {"staging failure preserves planned status", false}, + {"backup cleanup failure preserves replaced status", true}, + } { + t.Run(test.name, func(t *testing.T) { + fixture := newBundlePlanFixture(t) + fixture.input.Formats = []string{"pem"} + other := newECDSALeaf(t, fixture.ca, "z-next.example.com", nil) + if err := fixture.input.Store.HandleCertificate(other.cert, "next.pem"); err != nil { + t.Fatal(err) + } + fixture.input.Configs = append(fixture.input.Configs, BundleConfig{BundleName: "z-next", CommonNames: []string{"z-next.example.com"}}) + AssignBundleNames(fixture.input.Store, fixture.input.Configs) + initial, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := initial.Write(context.Background()); err != nil { + t.Fatal(err) + } + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + originalRemoveAll, originalWriteFile := exporterRemoveAll, exporterWriteFile + t.Cleanup(func() { exporterRemoveAll, exporterWriteFile = originalRemoveAll, originalWriteFile }) + backup := "" + if test.committed { + exporterRemoveAll = func(root *os.Root, name string) error { + path := filepath.Join(root.Name(), name) + if strings.Contains(filepath.Base(path), ".bak-") { + if _, err := os.Stat(filepath.Join(path, "manifest.json")); err == nil { + backup = path + return errInjectedWriteFailure + } + } + return originalRemoveAll(root, name) + } + } else { + exporterWriteFile = func(*os.Root, certstore.BundleFile) error { return errInjectedWriteFailure } + } + err = plan.Write(context.Background()) + if !errors.Is(err, errInjectedWriteFailure) { + t.Fatalf("underlying filesystem failure was lost: %v", err) + } + if !strings.Contains(err.Error(), `writing bundle "service-tls"`) { + t.Fatalf("failure did not identify the planned bundle: %v", err) + } + if errors.Is(err, errExportBundleCommittedCleanup) != test.committed { + t.Fatalf("incorrect commit outcome in error: %v", err) + } + for i, entry := range plan.Entries { + var manifest BundleExportEntry + if err := json.Unmarshal(mustReadTestFile(t, filepath.Join(entry.OutputDirectory, "manifest.json")), &manifest); err != nil { + t.Fatal(err) + } + if i == 0 && test.committed { + if entry.Status != "replaced" || manifest.Status != "replaced" { + t.Fatalf("committed result disagrees with disk: %s / %s", entry.Status, manifest.Status) + } + if !strings.Contains(err.Error(), backup) || backup == "" { + t.Fatal("cleanup error did not identify the retained backup") + } + } else if entry.Status != "planned" || manifest.Status != "created" { + t.Fatalf("uncommitted bundle changed: %s / %s", entry.Status, manifest.Status) + } + } + }) + } +} + +func TestBundlePlan_CanceledWriteCreatesNoOutput(t *testing.T) { + t.Parallel() + fixture := newBundlePlanFixture(t) + fixture.input.Formats = []string{"pem"} + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := plan.Write(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled write returned %v", err) + } + if _, err := os.Stat(fixture.input.OutDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("canceled write created output: %v", err) + } + if plan.Entries[0].Status != "planned" { + t.Fatalf("canceled write changed status: %s", plan.Entries[0].Status) + } +} + +func TestBundlePlan_PreservesEditsMadeDuringStaging(t *testing.T) { + // Filesystem injection must remain serial with other writer tests. + for _, target := range []string{"service-tls", "z-next"} { + t.Run(target, func(t *testing.T) { + fixture := newBundlePlanFixture(t) + fixture.input.Formats = []string{"pem"} + other := newECDSALeaf(t, fixture.ca, "z-next.example.com", nil) + if err := fixture.input.Store.HandleCertificate(other.cert, "next.pem"); err != nil { + t.Fatal(err) + } + fixture.input.Configs = append(fixture.input.Configs, BundleConfig{BundleName: "z-next", CommonNames: []string{"z-next.example.com"}}) + AssignBundleNames(fixture.input.Store, fixture.input.Configs) + initial, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + if err := initial.Write(context.Background()); err != nil { + t.Fatal(err) + } + plan, err := PlanBundleExports(context.Background(), fixture.input) + if err != nil { + t.Fatal(err) + } + changedPath := filepath.Join(fixture.input.OutDir, target, "operator-note") + originalWriteFile := exporterWriteFile + t.Cleanup(func() { exporterWriteFile = originalWriteFile }) + changed := false + exporterWriteFile = func(root *os.Root, file certstore.BundleFile) error { + if err := originalWriteFile(root, file); err != nil { + return err + } + if !changed && strings.HasPrefix(filepath.Base(root.Name()), ".certkit.tmp-") { + changed = true + if err := os.WriteFile(changedPath, []byte("preserve external edit"), 0600); err != nil { + t.Fatal(err) + } + } + return nil + } + writeErr := plan.Write(context.Background()) + if !errors.Is(writeErr, ErrBundlePlanBlocked) { + t.Fatalf("late edit did not block replacement: %v", writeErr) + } + if !strings.Contains(writeErr.Error(), `writing bundle "`+target+`"`) { + t.Fatalf("commit rejection omitted the bundle name: %v", writeErr) + } + if string(mustReadTestFile(t, changedPath)) != "preserve external edit" { + t.Fatal("late edit was overwritten") + } + for _, entry := range plan.Entries { + want := "planned" + if entry.BundleName == target { + want = "blocked" + } else if target == "z-next" { + want = "replaced" + } + if entry.Status != want { + t.Fatalf("%s status = %s, want %s", entry.BundleName, entry.Status, want) + } + } + children, err := os.ReadDir(fixture.input.OutDir) + if err != nil { + t.Fatal(err) + } + for _, child := range children { + if strings.Contains(child.Name(), ".tmp-") { + t.Fatal("blocked replacement left staged artifacts behind") + } + } + }) + } +} diff --git a/internal/certstore/export.go b/internal/certstore/export.go index da2d14b..f4361ec 100644 --- a/internal/certstore/export.go +++ b/internal/certstore/export.go @@ -26,6 +26,7 @@ var ( errBundleNil = errors.New("bundle is nil") errBundleLeafCertNil = errors.New("bundle leaf certificate is nil") errLeafCertificateNil = errors.New("leaf certificate is nil") + errBundleKeyMismatch = errors.New("bundle private key does not match the leaf certificate") ) // BundleFile represents a single output file in a bundle export. @@ -55,10 +56,12 @@ type BundleExportInput struct { Prefix string // sanitized file name prefix SecretName string // Kubernetes secret metadata.name CSRSubject *CSRSubjectOverride // optional; nil uses cert's own subject - // P12Password controls the .p12 output file password and must be explicit. + // P12Password controls the .p12 output file password and must be nonempty. P12Password string // EncryptKey when true encrypts the .key PEM output using PKCS#8 v2. EncryptKey bool + // Formats selects output artifacts. Nil preserves the complete legacy set. + Formats []string } // K8sSecret represents a Kubernetes TLS secret. @@ -76,126 +79,135 @@ type K8sMetadata struct { Annotations map[string]string `yaml:"annotations,omitempty"` } -// GenerateBundleFiles creates all output files for a certificate bundle. -// The returned files include PEM variants, private key, PKCS#12, Kubernetes -// TLS secret, JSON, YAML, CSR, and CSR JSON. Conditional files (intermediates, -// root) are only included when the corresponding certificates exist. +// GenerateBundleFiles creates the certificate bundle artifacts selected by +// input.Formats. Nil Formats preserves the full legacy set: PEM variants, +// private key, PKCS#12, Kubernetes TLS secret, JSON, YAML, CSR, and CSR JSON. +// A non-nil Formats slice limits output to the requested formats; an empty or +// invalid selection returns an error. Selected intermediates and root files +// are included only when the corresponding certificates exist. func GenerateBundleFiles(input BundleExportInput) ([]BundleFile, error) { bundle := input.Bundle if err := validateBundle(bundle); err != nil { return nil, err } + formats, err := NormalizeBundleFormats(input.Formats) + if err != nil { + return nil, err + } + wants := func(format string) bool { return slices.Contains(formats, format) } prefix := input.Prefix - leafPEM := []byte(certkit.CertToPEM(bundle.Leaf)) - - var intermediatePEM []byte + var intermediatePEM, rootPEM []byte for _, c := range bundle.Intermediates { intermediatePEM = append(intermediatePEM, []byte(certkit.CertToPEM(c))...) } - - var rootPEM []byte - root := bundleRoot(bundle) - if root != nil { + if root := bundleRoot(bundle); root != nil { rootPEM = []byte(certkit.CertToPEM(root)) } - chainPEM := slices.Concat(leafPEM, intermediatePEM) fullchainPEM := slices.Concat(chainPEM, rootPEM) - - files := []BundleFile{ - {Name: prefix + ".pem", Data: leafPEM}, - {Name: prefix + ".chain.pem", Data: chainPEM}, - {Name: prefix + ".fullchain.pem", Data: fullchainPEM}, - } - if len(intermediatePEM) > 0 { - files = append(files, BundleFile{Name: prefix + ".intermediates.pem", Data: intermediatePEM}) - } - if len(rootPEM) > 0 { - files = append(files, BundleFile{Name: prefix + ".root.pem", Data: rootPEM}) - } - - // Parse private key once for P12, CSR, and optional encryption. - privKey, err := certkit.ParsePEMPrivateKey(input.KeyPEM) - if err != nil { - return nil, fmt.Errorf("parsing private key: %w", err) - } - - // Normalize key output to PKCS#8 PEM regardless of input format. - pkcs8PEM, err := certkit.MarshalPrivateKeyToPEM(privKey) - if err != nil { - return nil, fmt.Errorf("marshaling private key to PKCS#8: %w", err) - } - pkcs8Bytes := []byte(pkcs8PEM) - - // Private key — encrypt when explicitly requested. - keyOutput := pkcs8Bytes - if input.EncryptKey { - encrypted, encErr := certkit.MarshalEncryptedPrivateKeyToPEM(privKey, input.P12Password) - if encErr != nil { - return nil, fmt.Errorf("encrypting private key PEM: %w", encErr) + var files []BundleFile + for _, artifact := range []struct { + format string + suffix string + data []byte + }{ + {"pem", ".pem", leafPEM}, + {"chain", ".chain.pem", chainPEM}, + {"fullchain", ".fullchain.pem", fullchainPEM}, + {"intermediates", ".intermediates.pem", intermediatePEM}, + {"root", ".root.pem", rootPEM}, + } { + if wants(artifact.format) && len(artifact.data) > 0 { + files = append(files, BundleFile{Name: prefix + artifact.suffix, Data: artifact.data}) } - keyOutput = []byte(encrypted) } - files = append(files, BundleFile{Name: prefix + ".key", Data: keyOutput, Sensitive: true}) - // PKCS#12 - p12Password := input.P12Password - if p12Password == "" { - return nil, errP12PasswordRequired - } - p12Data, err := certkit.EncodePKCS12Legacy(privKey, bundle.Leaf, bundle.Intermediates, p12Password) - if err != nil { - return nil, fmt.Errorf("creating P12: %w", err) - } - files = append(files, BundleFile{Name: prefix + ".p12", Data: p12Data, Sensitive: true}) - - // Kubernetes TLS secret - k8sSecret := K8sSecret{ - APIVersion: "v1", - Kind: "Secret", - Type: "kubernetes.io/tls", - Metadata: K8sMetadata{ - Name: input.SecretName, - }, - Data: map[string]string{ - "tls.crt": base64.StdEncoding.EncodeToString(chainPEM), - "tls.key": base64.StdEncoding.EncodeToString(pkcs8Bytes), - }, - } - k8sYAML, err := yaml.Marshal(k8sSecret) - if err != nil { - return nil, fmt.Errorf("marshaling kubernetes secret YAML: %w", err) + var pkcs8Bytes, keyOutput []byte + if BundleFormatsNeedKey(formats) { + privKey, err := certkit.ParsePEMPrivateKey(input.KeyPEM) + if err != nil { + return nil, fmt.Errorf("parsing private key: %w", err) + } + matches, err := certkit.KeyMatchesCert(privKey, bundle.Leaf) + if err != nil { + return nil, fmt.Errorf("matching bundle key: %w", err) + } + if !matches { + return nil, fmt.Errorf("matching bundle key: %w", errBundleKeyMismatch) + } + pkcs8PEM, err := certkit.MarshalPrivateKeyToPEM(privKey) + if err != nil { + return nil, fmt.Errorf("marshaling private key to PKCS#8: %w", err) + } + pkcs8Bytes = []byte(pkcs8PEM) + keyOutput = pkcs8Bytes + if input.EncryptKey && (wants("key") || wants("yaml")) { + encrypted, err := certkit.MarshalEncryptedPrivateKeyToPEM(privKey, input.P12Password) + if err != nil { + return nil, fmt.Errorf("encrypting private key PEM: %w", err) + } + keyOutput = []byte(encrypted) + } + if wants("key") { + files = append(files, BundleFile{Name: prefix + ".key", Data: keyOutput, Sensitive: true}) + } + if wants("p12") { + if input.P12Password == "" { + return nil, errP12PasswordRequired + } + p12Data, err := certkit.EncodePKCS12Legacy(privKey, bundle.Leaf, bundle.Intermediates, input.P12Password) + if err != nil { + return nil, fmt.Errorf("creating P12: %w", err) + } + files = append(files, BundleFile{Name: prefix + ".p12", Data: p12Data, Sensitive: true}) + } } - files = append(files, BundleFile{Name: prefix + ".k8s.yaml", Data: k8sYAML, Sensitive: true}) - if input.EncryptKey { - slog.Warn("kubernetes TLS secret contains unencrypted private key", - "file", prefix+".k8s.yaml", - "reason", "kubernetes.io/tls requires unencrypted tls.key") + if wants("k8s") { + secret := K8sSecret{ + APIVersion: "v1", Kind: "Secret", Type: "kubernetes.io/tls", + Metadata: K8sMetadata{Name: input.SecretName}, + Data: map[string]string{ + "tls.crt": base64.StdEncoding.EncodeToString(chainPEM), + "tls.key": base64.StdEncoding.EncodeToString(pkcs8Bytes), + }, + } + data, err := yaml.Marshal(secret) + if err != nil { + return nil, fmt.Errorf("marshaling kubernetes secret YAML: %w", err) + } + files = append(files, BundleFile{Name: prefix + ".k8s.yaml", Data: data, Sensitive: true}) + if input.EncryptKey { + slog.Warn("kubernetes TLS secret contains unencrypted private key", "file", prefix+".k8s.yaml", + "reason", "kubernetes.io/tls requires unencrypted tls.key") + } } - - // JSON - jsonData, err := GenerateJSON(bundle) - if err != nil { - return nil, fmt.Errorf("generating JSON: %w", err) + if wants("json") { + data, err := GenerateJSON(bundle) + if err != nil { + return nil, fmt.Errorf("generating JSON: %w", err) + } + files = append(files, BundleFile{Name: prefix + ".json", Data: data}) } - files = append(files, BundleFile{Name: prefix + ".json", Data: jsonData}) - - // YAML — use encrypted key when requested so the YAML doesn't leak plaintext. - yamlData, err := GenerateYAML(bundle, keyOutput, input.KeyType, input.BitLength) - if err != nil { - return nil, fmt.Errorf("generating YAML: %w", err) + if wants("yaml") { + data, err := GenerateYAML(bundle, keyOutput, input.KeyType, input.BitLength) + if err != nil { + return nil, fmt.Errorf("generating YAML: %w", err) + } + files = append(files, BundleFile{Name: prefix + ".yaml", Data: data, Sensitive: true}) } - files = append(files, BundleFile{Name: prefix + ".yaml", Data: yamlData, Sensitive: true}) - - // CSR - csrPEM, csrJSON, err := GenerateCSR(bundle.Leaf, pkcs8Bytes, input.CSRSubject) - if err != nil { - return nil, fmt.Errorf("generating CSR: %w", err) + if wants("csr") || wants("csr-json") { + csrPEM, csrJSON, err := GenerateCSR(bundle.Leaf, pkcs8Bytes, input.CSRSubject) + if err != nil { + return nil, fmt.Errorf("generating CSR: %w", err) + } + if wants("csr") { + files = append(files, BundleFile{Name: prefix + ".csr", Data: csrPEM}) + } + if wants("csr-json") { + files = append(files, BundleFile{Name: prefix + ".csr.json", Data: csrJSON}) + } } - files = append(files, BundleFile{Name: prefix + ".csr", Data: csrPEM}) - files = append(files, BundleFile{Name: prefix + ".csr.json", Data: csrJSON}) - return files, nil } @@ -383,8 +395,8 @@ type ExportMatchedBundleInput struct { } // ExportMatchedBundles builds certificate chains and writes bundle files for -// each matched key-cert pair. This is the shared orchestration used by both CLI -// and WASM exports. +// each matched key-cert pair. WASM and legacy internal exports use this +// orchestration; managed CLI exports use a separate preflight plan. func ExportMatchedBundles(ctx context.Context, input ExportMatchedBundleInput) error { intermediates := input.Store.Intermediates() opts := input.BundleOpts diff --git a/internal/certstore/export_formats.go b/internal/certstore/export_formats.go new file mode 100644 index 0000000..feb6519 --- /dev/null +++ b/internal/certstore/export_formats.go @@ -0,0 +1,50 @@ +package certstore + +import ( + "errors" + "fmt" + "slices" + "strings" +) + +var errBundleFormat = errors.New("invalid bundle output format") + +// BundleFormats returns all supported managed bundle artifact names. +func BundleFormats() []string { + return []string{"pem", "key", "chain", "fullchain", "intermediates", "root", "json", "yaml", "p12", "k8s", "csr", "csr-json"} +} + +// DefaultBundleFormats returns the managed CLI defaults: public certificates, +// metadata, a private-key file, and the legacy PKCS#12 archive. +func DefaultBundleFormats() []string { + return []string{"pem", "key", "chain", "fullchain", "intermediates", "root", "json", "p12"} +} + +// NormalizeBundleFormats validates and deduplicates artifact names. Nil means +// the complete legacy set for callers of GenerateBundleFiles. +func NormalizeBundleFormats(formats []string) ([]string, error) { + if formats == nil { + return BundleFormats(), nil + } + if len(formats) == 0 { + return nil, fmt.Errorf("%w: at least one artifact is required", errBundleFormat) + } + var result []string + for _, format := range formats { + format = strings.TrimSpace(format) + if !slices.Contains(BundleFormats(), format) { + return nil, fmt.Errorf("%w %q", errBundleFormat, format) + } + if !slices.Contains(result, format) { + result = append(result, format) + } + } + return result, nil +} + +// BundleFormatsNeedKey reports whether any selected artifact requires a key. +func BundleFormatsNeedKey(formats []string) bool { + return slices.ContainsFunc(formats, func(format string) bool { + return slices.Contains([]string{"key", "yaml", "p12", "k8s", "csr", "csr-json"}, format) + }) +} diff --git a/internal/certstore/memstore.go b/internal/certstore/memstore.go index f227a19..4ab38e7 100644 --- a/internal/certstore/memstore.go +++ b/internal/certstore/memstore.go @@ -1,10 +1,12 @@ package certstore import ( + "bytes" "crypto" "crypto/ecdsa" "crypto/ed25519" "crypto/rsa" + "crypto/sha256" "crypto/x509" "encoding/hex" "errors" @@ -333,7 +335,7 @@ func (s *MemStore) hasCertID(id string) bool { } // CertsByBundleName returns all certificates with the given bundle name, -// sorted by NotAfter descending (newest first). +// sorted by NotAfter, then NotBefore descending, then SHA-256 fingerprint ascending. func (s *MemStore) CertsByBundleName(name string) []*CertRecord { s.mu.RLock() defer s.mu.RUnlock() @@ -344,7 +346,7 @@ func (s *MemStore) CertsByBundleName(name string) []*CertRecord { } } sort.Slice(result, func(i, j int) bool { - return result[i].NotAfter.After(result[j].NotAfter) + return compareCertRecords(result[i], result[j]) < 0 }) return result } @@ -558,9 +560,21 @@ func latestCertRecord(certs []*CertRecord) *CertRecord { } latest := certs[0] for _, c := range certs[1:] { - if c.NotAfter.After(latest.NotAfter) { + if compareCertRecords(c, latest) < 0 { latest = c } } return latest } + +// compareCertRecords defines a total order independent of map and ingestion order. +func compareCertRecords(a, b *CertRecord) int { + if order := b.NotAfter.Compare(a.NotAfter); order != 0 { + return order + } + if order := b.NotBefore.Compare(a.NotBefore); order != 0 { + return order + } + aHash, bHash := sha256.Sum256(a.Cert.Raw), sha256.Sum256(b.Cert.Raw) + return bytes.Compare(aHash[:], bHash[:]) +} diff --git a/internal/exporter.go b/internal/exporter.go index 136f97c..a85c826 100644 --- a/internal/exporter.go +++ b/internal/exporter.go @@ -2,6 +2,7 @@ package internal import ( "context" + "crypto/rand" "crypto/x509" "errors" "fmt" @@ -16,24 +17,22 @@ import ( ) var ( - errExportBundleFolderEmpty = errors.New("bundle folder name is empty") - errExportBundleFolderRelative = errors.New("bundle folder name must be relative") - errExportBundleFolderEscapes = errors.New("bundle folder name escapes output dir") - errExportBundleFolderCollision = errors.New("sanitized bundle folder collision") - errExportBundlePathNotDir = errors.New("existing bundle path is not a directory") - - exporterMkdirAll = os.MkdirAll - exporterMkdir = os.Mkdir - exporterMkdirTemp = os.MkdirTemp - exporterWriteFile = os.WriteFile - exporterRename = os.Rename - exporterRemoveAll = os.RemoveAll - exporterStat = os.Stat + errExportBundleFolderEmpty = errors.New("bundle folder name is empty") + errExportBundleFolderRelative = errors.New("bundle folder name must be relative") + errExportBundleFolderEscapes = errors.New("bundle folder name escapes output dir") + errExportBundleFolderCollision = errors.New("sanitized bundle folder collision") + errExportBundlePathNotDir = errors.New("existing bundle path is not a directory") + errExportBundleCommittedCleanup = errors.New("bundle committed but backup cleanup failed") + + exporterWriteFile = writeExportFile + exporterRemoveAll = func(root *os.Root, name string) error { return root.RemoveAll(name) } ) // filesystemWriter writes bundle files to the local filesystem under outDir. type filesystemWriter struct { - outDir string + outDir string + beforeCommit func(stagingDir *os.Root) error + checkCommit func(parent *os.Root) error } // WriteBundleFiles creates the folder and writes each file with appropriate permissions. @@ -44,12 +43,23 @@ func (w *filesystemWriter) WriteBundleFiles(folder string, files []certstore.Bun } parentDir := filepath.Dir(folderPath) - if err := exporterMkdirAll(parentDir, 0o755); err != nil { + //nolint:gosec // Public bundle directories; private files use 0600. + if err := os.MkdirAll(parentDir, 0o755); err != nil { return fmt.Errorf("creating bundle parent directory %s: %w", parentDir, err) } + root, err := os.OpenRoot(parentDir) + if err != nil { + return fmt.Errorf("opening bundle parent directory: %w", err) + } + defer func() { + if err := root.Close(); err != nil { + slog.Warn("closing bundle parent directory", "error", err) + } + }() + folderName := filepath.Base(folderPath) dirMode := os.FileMode(0o755) - if info, statErr := exporterStat(folderPath); statErr == nil { + if info, statErr := root.Stat(folderName); statErr == nil { if !info.IsDir() { return fmt.Errorf("%w: %s", errExportBundlePathNotDir, folderPath) } @@ -58,11 +68,8 @@ func (w *filesystemWriter) WriteBundleFiles(folder string, files []certstore.Bun return fmt.Errorf("checking existing bundle directory: %w", statErr) } - tempDir, err := reserveTemporaryPath(parentDir, "."+filepath.Base(folderPath)+".tmp-") + tempDir, err := createExportTemporaryDirectory(root, exportTemporaryDirectoryInput{Prefix: ".certkit.tmp-", Mode: dirMode}) if err != nil { - return fmt.Errorf("reserving temporary bundle directory for %s: %w", folderPath, err) - } - if err := exporterMkdir(tempDir, dirMode); err != nil { return fmt.Errorf("creating temporary bundle directory for %s: %w", folderPath, err) } @@ -71,51 +78,91 @@ func (w *filesystemWriter) WriteBundleFiles(folder string, files []certstore.Bun if committed { return } - if removeErr := exporterRemoveAll(tempDir); removeErr != nil { + if removeErr := exporterRemoveAll(root, tempDir); removeErr != nil { slog.Warn("removing temporary bundle directory", "path", tempDir, "error", removeErr) } }() + stagingRoot, err := root.OpenRoot(tempDir) + if err != nil { + return fmt.Errorf("opening staged bundle directory: %w", err) + } + defer func() { + if err := stagingRoot.Close(); err != nil { + slog.Warn("closing staged bundle directory", "error", err) + } + }() for _, f := range files { - mode := os.FileMode(0644) - if f.Sensitive { - mode = 0600 - } - if err := exporterWriteFile(filepath.Join(tempDir, f.Name), f.Data, mode); err != nil { + if err := exporterWriteFile(stagingRoot, f); err != nil { return fmt.Errorf("writing %s: %w", f.Name, err) } } + if w.beforeCommit != nil { + if err := w.beforeCommit(stagingRoot); err != nil { + return fmt.Errorf("preparing staged bundle commit: %w", err) + } + } - if err := replaceDirectoryAtomically(tempDir, folderPath); err != nil { + if err := replaceDirectoryAtomically(root, replaceDirectoryInput{Temporary: tempDir, Destination: folderName, BeforeRename: w.checkCommit}); err != nil { + committed = errors.Is(err, errExportBundleCommittedCleanup) return fmt.Errorf("committing bundle directory %s: %w", folderPath, err) } committed = true return nil } -func replaceDirectoryAtomically(tempDir, folderPath string) error { - parentDir := filepath.Dir(folderPath) - backupDir, err := reserveTemporaryPath(parentDir, "."+filepath.Base(folderPath)+".bak-") +func writeExportFile(root *os.Root, file certstore.BundleFile) error { + mode := os.FileMode(0644) + if file.Sensitive { + mode = 0600 + } + if err := root.WriteFile(file.Name, file.Data, mode); err != nil { + return fmt.Errorf("writing staged artifact %q: %w", file.Name, err) + } + return nil +} + +type replaceDirectoryInput struct { + Temporary string + Destination string + BeforeRename func(parent *os.Root) error +} + +func replaceDirectoryAtomically(root *os.Root, input replaceDirectoryInput) error { + backupDir, err := createExportTemporaryDirectory(root, exportTemporaryDirectoryInput{Prefix: ".certkit.bak-", Mode: 0700}) if err != nil { return fmt.Errorf("reserving backup directory path: %w", err) } + if err := root.Remove(backupDir); err != nil { + return fmt.Errorf("releasing reserved backup path: %w", err) + } hadExisting := false - if info, err := exporterStat(folderPath); err == nil { + if info, err := root.Stat(input.Destination); err == nil { if !info.IsDir() { - return fmt.Errorf("%w: %s", errExportBundlePathNotDir, folderPath) - } - if err := exporterRename(folderPath, backupDir); err != nil { - return fmt.Errorf("moving existing bundle aside: %w", err) + return fmt.Errorf("%w: %s", errExportBundlePathNotDir, input.Destination) } hadExisting = true } else if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("checking existing bundle directory: %w", err) } - if err := exporterRename(tempDir, folderPath); err != nil { + // Check after every staged artifact, including the manifest, and immediately + // before the first rename through this exact parent handle. + if input.BeforeRename != nil { + if err := input.BeforeRename(root); err != nil { + return fmt.Errorf("validating bundle commit: %w", err) + } + } + if hadExisting { + if err := root.Rename(input.Destination, backupDir); err != nil { + return fmt.Errorf("moving existing bundle aside: %w", err) + } + } + + if err := root.Rename(input.Temporary, input.Destination); err != nil { if hadExisting { - if restoreErr := exporterRename(backupDir, folderPath); restoreErr != nil { + if restoreErr := root.Rename(backupDir, input.Destination); restoreErr != nil { return fmt.Errorf( "moving staged bundle into place: %w", errors.Join(err, fmt.Errorf("restoring previous bundle: %w", restoreErr)), @@ -126,22 +173,30 @@ func replaceDirectoryAtomically(tempDir, folderPath string) error { } if hadExisting { - if err := exporterRemoveAll(backupDir); err != nil { - return fmt.Errorf("removing replaced bundle backup: %w", err) + if err := exporterRemoveAll(root, backupDir); err != nil { + return fmt.Errorf("%w: removing %s: %w", errExportBundleCommittedCleanup, filepath.Join(root.Name(), backupDir), err) } } return nil } -func reserveTemporaryPath(parentDir, pattern string) (string, error) { - path, err := exporterMkdirTemp(parentDir, pattern) - if err != nil { - return "", err - } - if err := exporterRemoveAll(path); err != nil { - return "", fmt.Errorf("releasing reserved path %s: %w", path, err) +type exportTemporaryDirectoryInput struct { + Prefix string + Mode os.FileMode +} + +func createExportTemporaryDirectory(root *os.Root, input exportTemporaryDirectoryInput) (string, error) { + for { + name := input.Prefix + rand.Text() + if err := root.Mkdir(name, input.Mode); err != nil { + if errors.Is(err, os.ErrExist) { + slog.Debug("retrying temporary directory name collision", "name", name) + continue + } + return "", fmt.Errorf("creating temporary directory: %w", err) + } + return name, nil } - return path, nil } // safeJoin joins base and folder while ensuring the result stays within base. diff --git a/internal/exporter_test.go b/internal/exporter_test.go index 2c4bd70..43ebb8e 100644 --- a/internal/exporter_test.go +++ b/internal/exporter_test.go @@ -179,12 +179,12 @@ func TestFilesystemWriter_WriteBundleFilesPreservesExistingBundleOnFailure(t *te }) writeCount := 0 - exporterWriteFile = func(name string, data []byte, perm os.FileMode) error { + exporterWriteFile = func(root *os.Root, file certstore.BundleFile) error { writeCount++ if writeCount == 2 { return errInjectedWriteFailure } - return originalWriteFile(name, data, perm) + return originalWriteFile(root, file) } writer := &filesystemWriter{outDir: outDir} diff --git a/internal/scanwalk.go b/internal/scanwalk.go index 992bba6..5158204 100644 --- a/internal/scanwalk.go +++ b/internal/scanwalk.go @@ -13,13 +13,16 @@ import ( var ( errScanRootPathRequired = errors.New("root path is required") errScanFileHandlerNil = errors.New("file handler is required") + errScanInputExcluded = errors.New("scan input cannot also be a declared output, database, or password file") ) // WalkScanFilesInput configures WalkScanFiles. type WalkScanFilesInput struct { RootPath string MaxFileSize int64 - OnFile func(path string) error + // ExcludePaths omits declared outputs, databases, and secret input files. + ExcludePaths []string + OnFile func(path string) error } // WalkScanFiles iterates scan-eligible files under RootPath. @@ -31,6 +34,43 @@ func WalkScanFiles(input WalkScanFilesInput) error { return errScanFileHandlerNil } + excluded := make(map[string]bool) + for _, path := range input.ExcludePaths { + if path == "" { + continue + } + absolute, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("resolving excluded scan path: %w", err) + } + excluded[absolute] = true + canonical, err := resolveBundleControlPath(path) + if err != nil { + return fmt.Errorf("resolving excluded scan target: %w", err) + } + excluded[canonical] = true + } + isExcluded := func(path string) (bool, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return false, fmt.Errorf("resolving scan path: %w", err) + } + canonical, err := scanRootBoundary(path) + if err != nil { + return false, fmt.Errorf("resolving scan target: %w", err) + } + for excludedPath := range excluded { + if bundlePathWithinBoundary(absolute, excludedPath) || bundlePathWithinBoundary(canonical, excludedPath) { + return true, nil + } + } + return false, nil + } + if skip, err := isExcluded(input.RootPath); err != nil { + return err + } else if skip { + return fmt.Errorf("checking scan root %s: %w", input.RootPath, errScanInputExcluded) + } info, err := os.Stat(input.RootPath) if err != nil { return fmt.Errorf("stat %s: %w", input.RootPath, err) @@ -62,8 +102,18 @@ func WalkScanFiles(input WalkScanFilesInput) error { } return nil } + skip, err := isExcluded(path) + if err != nil { + return err + } + if skip { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } if d.IsDir() { - if IsSkippableDir(d.Name()) { + if path != input.RootPath && IsSkippableDir(d.Name()) { slog.Debug("skipping directory", "path", path) return filepath.SkipDir } @@ -112,7 +162,11 @@ func scanRootBoundary(root string) (string, error) { if absErr != nil { return "", fmt.Errorf("absolute path for %s: %w", resolved, absErr) } - return absResolved, nil + canonical, err := filepath.EvalSymlinks(absResolved) + if err != nil { + return "", fmt.Errorf("resolving absolute scan target %s: %w", absResolved, err) + } + return canonical, nil } absRoot, absErr := filepath.Abs(root) if absErr != nil { @@ -122,7 +176,7 @@ func scanRootBoundary(root string) (string, error) { } func resolveScanSymlink(path string) (string, os.FileInfo, bool) { - resolvedPath, err := filepath.EvalSymlinks(path) + resolvedPath, err := scanRootBoundary(path) if err != nil { slog.Debug("skipping broken symlink", "path", path) return "", nil, false diff --git a/internal/scanwalk_test.go b/internal/scanwalk_test.go index 8f15bda..b221c29 100644 --- a/internal/scanwalk_test.go +++ b/internal/scanwalk_test.go @@ -10,6 +10,86 @@ import ( var errOnFileFailed = errors.New("onfile failed") +func TestWalkScanFiles_ExcludesAcrossWorkingDirectorySymlinks(t *testing.T) { + // Chdir changes process state, so these cases must remain serial. + for _, relativeExclusions := range []bool{true, false} { + name := "relative exclusions with absolute scan root" + if !relativeExclusions { + name = "absolute exclusions with relative scan root" + } + t.Run(name, func(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "real") + if err := os.MkdirAll(filepath.Join(root, "bundles"), 0700); err != nil { + t.Fatal(err) + } + root, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"delivery.pem", "password", "bundles/old.key"} { + if err := os.WriteFile(filepath.Join(root, path), []byte("fixture"), 0600); err != nil { + t.Fatal(err) + } + } + createSymlinkOrSkip(t, "delivery.pem", filepath.Join(root, "delivery-link")) + alias := filepath.Join(parent, "alias") + createSymlinkOrSkip(t, root, alias) + t.Chdir(alias) + var visited []string + input := WalkScanFilesInput{RootPath: root, ExcludePaths: []string{"bundles", "password"}, + OnFile: func(path string) error { visited = append(visited, filepath.Base(path)); return nil }} + if !relativeExclusions { + input.RootPath = "." + input.ExcludePaths = []string{filepath.Join(root, "bundles"), filepath.Join(root, "password")} + } + if err := WalkScanFiles(input); err != nil { + t.Fatal(err) + } + slices.Sort(visited) + if !slices.Equal(visited, []string{"delivery-link", "delivery.pem"}) { + t.Fatalf("symlinked working directory changed exclusions or valid inputs: %v", visited) + } + }) + } +} + +func TestWalkScanFiles_ExcludesFutureOutputThroughSymlink(t *testing.T) { + t.Parallel() + root := t.TempDir() + parent := filepath.Join(root, "z-parent") + if err := os.Mkdir(parent, 0700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "alias") + createSymlinkOrSkip(t, parent, alias) + trigger := filepath.Join(root, "a-delivery.pem") + if err := os.WriteFile(trigger, []byte("input"), 0600); err != nil { + t.Fatal(err) + } + output := filepath.Join(parent, "bundles") + var visited []string + err := WalkScanFiles(WalkScanFilesInput{RootPath: root, ExcludePaths: []string{filepath.Join(alias, "bundles")}, + OnFile: func(path string) error { + visited = append(visited, path) + if path == trigger { + if err := os.Mkdir(output, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(output, "old.key"), []byte("output"), 0600); err != nil { + t.Fatal(err) + } + } + return nil + }}) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(visited, []string{trigger}) { + t.Fatalf("newly created output through symlink was ingested: %v", visited) + } +} + func TestWalkScanFiles_SkipsSymlinkOutsideRoot(t *testing.T) { // WHY: Directory scans must stay within the requested root and avoid // ingesting symlink targets from unrelated paths. @@ -164,3 +244,135 @@ func TestWalkScanFiles_PropagatesOnFileError(t *testing.T) { t.Fatalf("error = %v, want wrapped %v", err, wantErr) } } + +func TestWalkScanFiles_ExcludesSymlinkTargets(t *testing.T) { + t.Parallel() + for _, aliasedExclusion := range []bool{false, true} { + name := "direct exclusion" + if aliasedExclusion { + name = "aliased exclusion" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + output := filepath.Join(root, "bundles") + if err := os.Mkdir(output, 0700); err != nil { + t.Fatal(err) + } + secret := filepath.Join(root, "password") + artifact := filepath.Join(output, "old.key") + input := filepath.Join(root, "delivery.pem") + for _, path := range []string{secret, artifact, input} { + if err := os.WriteFile(path, []byte("fixture"), 0600); err != nil { + t.Fatal(err) + } + } + secretAlias := filepath.Join(root, "password-alias") + outputAlias := filepath.Join(root, "bundles-alias") + artifactAlias := filepath.Join(root, "old-key-alias") + createSymlinkOrSkip(t, secret, secretAlias) + createSymlinkOrSkip(t, output, outputAlias) + createSymlinkOrSkip(t, artifact, artifactAlias) + exclusions := []string{output, secret, filepath.Join(root, "future-output")} + if aliasedExclusion { + exclusions = []string{outputAlias, secretAlias} + } + var visited []string + inputOpts := WalkScanFilesInput{RootPath: root, ExcludePaths: exclusions, OnFile: func(path string) error { + visited = append(visited, path) + return nil + }} + if err := WalkScanFiles(inputOpts); err != nil { + t.Fatal(err) + } + if !slices.Equal(visited, []string{input}) { + t.Fatalf("visited %v, want only vendor delivery", visited) + } + for _, path := range []string{secret, secretAlias, artifact, artifactAlias, output, outputAlias} { + inputOpts.RootPath = path + if err := WalkScanFiles(inputOpts); !errors.Is(err, errScanInputExcluded) { + t.Fatalf("explicit excluded input %s: %v", path, err) + } + } + }) + } +} + +func TestWalkScanFiles_ExcludesOutputsAndSecrets(t *testing.T) { + t.Parallel() + root := filepath.Join(t.TempDir(), "vendor") + output := filepath.Join(root, "managed") + if err := os.MkdirAll(output, 0700); err != nil { + t.Fatal(err) + } + input := filepath.Join(root, "delivery.pem") + secret := filepath.Join(root, "password") + for _, path := range []string{input, secret, filepath.Join(output, "old.pem")} { + if err := os.WriteFile(path, []byte("fixture"), 0600); err != nil { + t.Fatal(err) + } + } + var visited []string + if err := WalkScanFiles(WalkScanFilesInput{RootPath: root, ExcludePaths: []string{output, secret}, OnFile: func(path string) error { + visited = append(visited, path) + return nil + }}); err != nil { + t.Fatal(err) + } + if !slices.Equal(visited, []string{input}) { + t.Fatalf("visited %v, want only explicit vendor delivery", visited) + } +} + +func TestWalkScanFiles_ExcludesPortablePathAliases(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + actual string + excluded string + }{ + {"case variant", "Bundles", "bundles"}, + {"Unicode normalization", "caf\u00e9", "cafe\u0301"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + output := filepath.Join(root, test.actual) + sibling := filepath.Join(root, test.actual+"-archive") + for _, directory := range []string{output, sibling} { + if err := os.Mkdir(directory, 0700); err != nil { + t.Fatal(err) + } + } + secret := filepath.Join(root, test.actual+"-password") + artifact := filepath.Join(output, "old.key") + input := filepath.Join(root, "delivery.pem") + siblingInput := filepath.Join(sibling, "delivery.pem") + for _, path := range []string{secret, artifact, input, siblingInput} { + if err := os.WriteFile(path, []byte("fixture"), 0600); err != nil { + t.Fatal(err) + } + } + createSymlinkOrSkip(t, artifact, filepath.Join(root, "old-key-alias")) + var visited []string + opts := WalkScanFilesInput{RootPath: root, + ExcludePaths: []string{filepath.Join(root, test.excluded), filepath.Join(root, test.excluded+"-password")}, + OnFile: func(path string) error { visited = append(visited, path); return nil }} + if err := WalkScanFiles(opts); err != nil { + t.Fatal(err) + } + want := []string{input, siblingInput} + slices.Sort(want) + slices.Sort(visited) + if !slices.Equal(visited, want) { + t.Fatalf("excluded alias was ingested: %v, want %v", visited, want) + } + for _, path := range []string{output, artifact, secret} { + opts.RootPath = path + if err := WalkScanFiles(opts); !errors.Is(err, errScanInputExcluded) { + t.Fatalf("explicit excluded alias %s was accepted: %v", path, err) + } + } + }) + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 795b991..e14eb36 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,33 +14,33 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz", - "integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz", + "integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.3.0", - "@csstools/css-color-parser": "^4.2.0", + "@csstools/css-calc": "^3.4.0", + "@csstools/css-color-parser": "^4.2.3", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.5.2" + "@csstools/css-tokenizer": "^4.0.1", + "lru-cache": "^11.5.3" }, "engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.0.tgz", - "integrity": "sha512-qKvLetaSd/OTWif9JT0eu1SHBg3H7Nvp9SoMwMxqT5tmYyrxea/P1A5pIfGbnIJlOtm3yVeqw+ORxDOkjN1aEw==", + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz", + "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==", "dev": true, "license": "MIT", "dependencies": { "bidi-js": "^1.1.0", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.2" + "lru-cache": "^11.5.3" }, "engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" @@ -60,9 +60,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "5.20260920.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260920.1.tgz", - "integrity": "sha512-wP7wbKqfQjFRGhE/tvu7bwvmBBTqJjvrETTvNzF4u5oolVEXt0gy6JxbqcUC2Khz0Cy+M/q7rhOOODng8VpnPw==", + "version": "5.20260922.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260922.1.tgz", + "integrity": "sha512-zZVBBFJan5Kw+flEgSHqqCZ0LvHPv5LvfP2igmQiLvZ4fFQYsI4epN9cGiCQqaCyhCzQuqWEyK1Lamzm3QJAmw==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -207,9 +207,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", "dev": true, "license": "MIT", "engines": { @@ -788,16 +788,16 @@ } }, "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", "dev": true, "license": "MIT", "dependencies": { - "@exodus/bytes": "^1.6.0" + "@exodus/bytes": "^1.15.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" } }, "node_modules/is-potential-custom-element-name": { @@ -808,28 +808,28 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz", - "integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==", + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^7.0.0", - "@asamuzakjp/dom-selector": "^9.1.2", + "@asamuzakjp/dom-selector": "^9.2.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.13", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", + "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "tough-cookie": "^6.0.2", "undici": "^8.10.2", - "w3c-xmlserializer": "^5.0.0", + "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^17.1.1", @@ -1394,22 +1394,22 @@ } }, "node_modules/tldts": { - "version": "7.4.13", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", - "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "version": "7.4.14", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.14.tgz", + "integrity": "sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.13" + "tldts-core": "^7.4.14" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.13", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", - "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "version": "7.4.14", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.14.tgz", + "integrity": "sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==", "dev": true, "license": "MIT" }, @@ -1440,9 +1440,9 @@ } }, "node_modules/undici": { - "version": "8.10.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", - "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz", + "integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==", "dev": true, "license": "MIT", "engines": { @@ -1612,16 +1612,16 @@ } }, "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=18" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/webidl-conversions": { @@ -1645,9 +1645,9 @@ } }, "node_modules/whatwg-url": { - "version": "17.1.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", - "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", "dev": true, "license": "MIT", "dependencies": {