Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Thin CLI layer. Each file is one Cobra command. Flag variables are package-level
- `probe_ssh.go` — `probe ssh` subcommand. Connects without authenticating, prints banner/algorithm details, and supports `--fips-140-2` / `--fips-140-3` policy heuristics for SSH transport algorithms.
- `policy.go` — Shared CLI flag-to-policy selection helper used by `connect` and `probe ssh`.
- `sign.go` — Sign certificates. Parent command with `self-signed` and `csr` subcommands for creating self-signed certs and signing CSRs with a CA.
- `tree.go` — `tree` subcommand. Renders the full Cobra command graph, including built-in commands plus local and inherited flags accepted by each command; honors the global `--json` flag with structured command/flag output.
- `tree.go` — `tree` subcommand. Renders the full Cobra command graph with a command-focused default text view; `--flags` and `--inherited` opt into text-mode flag detail, and the global `--json` flag still returns the structured command/flag surface.
- `ocsp.go` — Check certificate revocation status via OCSP; `--format` flag.
- `crl.go` — Parse and inspect Certificate Revocation Lists; `--check` to verify a cert against the CRL; `--format` flag.
- `convert.go` — Convert certificates and keys between PEM, DER, PKCS#12, JKS, and PKCS#7 formats; `--to`, `-o` flags.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Parse extensionless or renamed DER/PKCS#7/PKCS#12/JKS inputs during certstore ingestion instead of skipping binary crypto content based on filename extension alone ([#172])
- Harden browser/WASM AIA and upload handling by blocking obvious internal hostnames without DNS, streaming AIA response-size enforcement before buffering, and rejecting oversized uploads before `arrayBuffer()` reads them into JS memory ([#172])
- Stop draining oversized `tar.gz` members after the size violation is already known, treat `gh api graphql` string variables literally in `pr-comments.py`, and add JSON output support for `certkit tree --json` so the global flag matches documented behavior ([#172])
- Make `certkit tree` default to a command-focused text view, with `--flags` and `--inherited` available when you want flag detail in text mode ([#172])
- Encrypt private key in YAML bundle output (`.yaml`) when an export password is supplied; previously leaked plaintext key ([#167])
- Reject malformed `ENCRYPTED PRIVATE KEY` blocks with invalid AES IV length instead of panicking ([#167])
- Trim whitespace from web UI export password so whitespace-only input is treated as blank ([#167])
Expand Down
9 changes: 7 additions & 2 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ A practical guide to common certificate tasks. No prior TLS/SSL knowledge requir

### See the full command surface

Print the actual CLI command tree, including built-in Cobra commands and the
flags each command accepts:
Print the actual CLI command tree, including built-in Cobra commands:

```sh
certkit tree
Expand All @@ -84,6 +83,12 @@ certkit tree
This is useful when you want a quick map of the CLI without hopping through
`--help` output command by command.

If you also want the flag surface, opt in explicitly:

```sh
certkit tree --flags --inherited
```

---

## Inspecting
Expand Down
35 changes: 35 additions & 0 deletions RALPH.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,38 @@ Area: [internal/certstore/process_test.go](internal/certstore/process_test.go)
Summary: direct unit tests of unexported algorithm-conversion helpers were too implementation-coupled for the compat parser behavior they were intended to protect.
Source: Follow-up PR review
Fix: Removed the helper-specific tests and asserted public-key/signature algorithm mapping through the compatibility ingestion path instead

33. `tree-default-flags-noise`
Status: fixed
Area: [cmd/certkit/tree.go](cmd/certkit/tree.go), [cmd/certkit/cli_semantics_test.go](cmd/certkit/cli_semantics_test.go), [README.md](README.md), [EXAMPLES.md](EXAMPLES.md)
Summary: the default `tree` output included every local and inherited flag, which made the command map harder to scan than a command-focused tree.
Source: User feedback
Fix: `tree` now defaults to commands-only text output, with `--flags` and `--inherited` opt-ins for text-mode flag detail; JSON output remains the detailed machine-readable surface

34. `validate-duplicate-ski-fallback`
Status: fixed
Area: [internal/certstore/validate.go](internal/certstore/validate.go), [internal/certstore/validate_test.go](internal/certstore/validate_test.go)
Summary: validation errored on duplicate-SKI renewal sets even though the rest of the store/UI already presents one latest-expiring certificate per SKI.
Source: Follow-up PR review
Fix: `RunValidation()` now uses the store's latest-cert selection for a given SKI, and the regression test proves the later renewal is the record that gets validated

35. `readme-tail-noise-and-command-overclaim`
Status: fixed
Area: [README.md](README.md)
Summary: the README command index overclaimed the full CLI surface and the end-of-file mermaid diagram added noise without carrying much durable documentation value.
Source: User feedback
Fix: narrowed the section to “Common Commands”, clarified the `tree` entry, removed the low-value diagram, and kept the useful scan behavior notes as prose

36. `validate-latest-fallback-assertion-gap`
Status: fixed
Area: [internal/certstore/validate_test.go](internal/certstore/validate_test.go)
Summary: the duplicate-SKI regression only asserted the selected subject, so it could still pass if validation picked the wrong renewal by insertion order instead of latest `NotAfter`.
Source: Follow-up PR review
Fix: pinned the expected later renewal `NotAfter` and asserted the exact RFC3339 result emitted by `RunValidation()`

37. `readme-followup-runtime-drift`
Status: fixed
Area: [README.md](README.md)
Summary: several README details had drifted from current behavior, including `tree` flag wording, CRL URL schemes, duplicate bundle export paths, library `SignCSR` SAN copying, and scan issuer-linkage notes.
Source: Follow-up docs audit finding
Fix: aligned the README text and library example with the current command/runtime behavior
25 changes: 7 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ certkit bundle cert.pem -o chain.pem

See [EXAMPLES.md](EXAMPLES.md) for a walkthrough of the main certificate workflows and real-world scenarios.

## Commands
## Common Commands

| Command | What it does |
| --------------------------- | ------------------------------------------------------- |
Expand All @@ -110,7 +110,7 @@ See [EXAMPLES.md](EXAMPLES.md) for a walkthrough of the main certificate workflo
| `certkit sign self-signed` | Create a self-signed certificate |
| `certkit sign csr <file>` | Sign a CSR with a CA certificate and key |
| `certkit scan <path>` | Scan a directory and catalog everything found |
| `certkit tree` | Print the full CLI command and flag surface as a tree |
| `certkit tree` | Print the full CLI command tree (`--flags`/`--inherited` for details) |
| `certkit keygen` | Generate a new key pair (and optionally a CSR) |
| `certkit csr` | Generate a CSR from a template, cert, or existing CSR |
| `certkit ocsp <file>` | Check certificate revocation status via OCSP |
Expand Down Expand Up @@ -319,7 +319,7 @@ The OCSP responder URL is read from the certificate's AIA extension.
| `--format` | `text` | Output format: text, json |
<!-- /certkit:flags -->

Accepts local files (PEM or DER) or HTTP URLs.
Accepts local files (PEM or DER) or HTTP/HTTPS URLs.

### Exit Codes

Expand Down Expand Up @@ -363,7 +363,7 @@ 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 `<dir>/<bundleName>/`:
When running `certkit scan --bundle-path`, each bundle produces the following files under `<dir>/<bundleName>/`. If `--duplicates` keeps older matching certificates, those extra exports are written under suffixed directories like `<bundleName>_<RFC3339>_<serial>/`:

| File | Contents |
| ------------------------ | ------------------------------------------------------------------------------------- |
Expand Down Expand Up @@ -417,7 +417,7 @@ csrPEM, keyPEM, _ := certkit.GenerateCSR(leaf, nil) // auto-generates EC P-256 k

// Sign certificates
selfSigned, _ := certkit.CreateSelfSigned(certkit.SelfSignedInput{Signer: caKey, Subject: pkix.Name{CommonName: "My CA"}, IsCA: true})
issued, _ := certkit.SignCSR(certkit.SignCSRInput{CSR: csr, CACert: caCert, CAKey: caKey, Days: 365})
issued, _ := certkit.SignCSR(certkit.SignCSRInput{CSR: csr, CACert: caCert, CAKey: caKey, Days: 365, CopySANs: true})

// TLS connection probing
result, _ := certkit.ConnectTLS(ctx, certkit.ConnectTLSInput{Host: "example.com"})
Expand All @@ -434,17 +434,6 @@ jks, _ := certkit.EncodeJKS(key, leaf, intermediates, "changeit")
encPEM, _ := certkit.MarshalEncryptedPrivateKeyToPEM(key, "secret")
```

### How It Works

```mermaid
flowchart TD
A[Input files / stdin] --> B[Format detection - PEM vs DER]
B --> C[Parse certs, keys, CSRs<br/>PKCS#12, PKCS#7, JKS, encrypted PEM, PKCS#8, SEC1, Ed25519]
C --> D[Catalog in MemStore<br/>certificates + keys indexed by SKI]
D --> E[Resolve AKIs<br/>match legacy SHA-1 AKIs to computed RFC 7093 M1 SKIs]
E --> F{--bundle-path?}
F -- yes --> G[Match keys to certs, build chains,<br/>write all output formats per bundle]
F -- no --> H[Print scan summary]
```
### Scan Notes

Expired certificates are always ingested; expiry filtering is output-only (`--allow-expired` overrides). SKI computation uses RFC 7093 Method 1 (SHA-256 truncated to 160 bits). Non-root certificate AKIs are resolved post-ingestion by matching against a multi-hash lookup (RFC 7093 M1 + legacy SHA-1) of all CA certificates.
Expired certificates are always ingested; expiry filtering is output-only (`--allow-expired` overrides). SKI computation uses RFC 7093 Method 1 (SHA-256 truncated to 160 bits). Non-root issuer linkage is resolved after ingestion by checking for raw ASN.1 subject/issuer matches among CA certificates and falling back to AIA fetching when the issuer is still missing.
68 changes: 60 additions & 8 deletions cmd/certkit/cli_semantics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,23 @@ func TestTreeCommand(t *testing.T) {
}
})

t.Run("includes help and version flags", func(t *testing.T) {
t.Run("default text output omits flags", func(t *testing.T) {
for _, flag := range []string{"--help", "--version"} {
if !strings.Contains(rootOutput, flag) {
t.Errorf("tree output missing flag %q", flag)
if strings.Contains(rootOutput, flag) {
t.Errorf("default tree output should omit flag %q", flag)
}
}
})

t.Run("includes subcommand help and inherited global flags", func(t *testing.T) {
t.Run("includes subcommand help and inherited global flags when requested", func(t *testing.T) {
prevTreeIncludeFlags := treeIncludeFlags
prevTreeIncludeInherited := treeIncludeInherited
t.Cleanup(func() {
treeIncludeFlags = prevTreeIncludeFlags
treeIncludeInherited = prevTreeIncludeInherited
})
treeIncludeFlags = true
treeIncludeInherited = true
Comment thread
danielewood marked this conversation as resolved.
var bundleTree strings.Builder
fmt.Fprintf(&bundleTree, "%s — %s\n", bundleCmd.Name(), bundleCmd.Short)
printCommandTree(&bundleTree, printCommandTreeInput{cmd: bundleCmd})
Expand All @@ -173,22 +181,66 @@ func TestTreeCommand(t *testing.T) {
})

t.Run("prefers long flag names over shorthand pairs", func(t *testing.T) {
prevTreeIncludeFlags := treeIncludeFlags
prevTreeIncludeInherited := treeIncludeInherited
t.Cleanup(func() {
treeIncludeFlags = prevTreeIncludeFlags
treeIncludeInherited = prevTreeIncludeInherited
})
treeIncludeFlags = true
treeIncludeInherited = true
var flagsTree strings.Builder
printCommandTree(&flagsTree, printCommandTreeInput{cmd: rootCmd})
flagsOutput := flagsTree.String()
for _, flag := range []string{"-h, --help", "-l, --log-level", "-p, --passwords", "-v, --verbose"} {
if strings.Contains(rootOutput, flag) {
if strings.Contains(flagsOutput, flag) {
t.Errorf("tree output should omit shorthand pair %q", flag)
}
}
})

t.Run("collapses inherited flags to one summary line", func(t *testing.T) {
if strings.Count(rootOutput, "inherits: ") == 0 {
t.Run("collapses inherited flags to one summary line when requested", func(t *testing.T) {
prevTreeIncludeFlags := treeIncludeFlags
prevTreeIncludeInherited := treeIncludeInherited
t.Cleanup(func() {
treeIncludeFlags = prevTreeIncludeFlags
treeIncludeInherited = prevTreeIncludeInherited
})
treeIncludeFlags = true
treeIncludeInherited = true
var flagsTree strings.Builder
printCommandTree(&flagsTree, printCommandTreeInput{cmd: rootCmd})
flagsOutput := flagsTree.String()
if strings.Count(flagsOutput, "inherits: ") == 0 {
t.Fatal("tree output missing inherited flag summaries")
}
if strings.Count(rootOutput, "\n│ ├── --json\n")+strings.Count(rootOutput, "\n│ └── --json\n") != 0 {
if strings.Count(flagsOutput, "\n│ ├── --json\n")+strings.Count(flagsOutput, "\n│ └── --json\n") != 0 {
t.Fatal("tree output should not repeat inherited flags as standalone subcommand entries")
}
})

t.Run("includes local flags only when requested", func(t *testing.T) {
prevTreeIncludeFlags := treeIncludeFlags
prevTreeIncludeInherited := treeIncludeInherited
t.Cleanup(func() {
treeIncludeFlags = prevTreeIncludeFlags
treeIncludeInherited = prevTreeIncludeInherited
})
treeIncludeFlags = true
treeIncludeInherited = false
var flagsTree strings.Builder
printCommandTree(&flagsTree, printCommandTreeInput{cmd: rootCmd})
flagsOutput := flagsTree.String()
for _, flag := range []string{"--help", "--version"} {
if !strings.Contains(flagsOutput, flag) {
t.Errorf("tree output missing requested local flag %q", flag)
}
}
if strings.Contains(flagsOutput, "inherits: ") {
t.Fatal("tree output should omit inherited summaries unless requested")
}
})

t.Run("rejects arguments", func(t *testing.T) {
if treeCmd.Args == nil {
t.Fatal("treeCmd.Args is nil; expected cobra.NoArgs")
Expand Down
10 changes: 10 additions & 0 deletions cmd/certkit/readonly_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ type readonlyGlobals struct {
// inspect flags
inspectFormat string
inspectAllowPrivateNetwork bool

// tree flags
treeIncludeFlags bool
treeIncludeInherited bool
}

func snapshotReadonlyGlobals() readonlyGlobals {
Expand Down Expand Up @@ -120,6 +124,9 @@ func snapshotReadonlyGlobals() readonlyGlobals {

inspectFormat: inspectFormat,
inspectAllowPrivateNetwork: inspectAllowPrivateNetwork,

treeIncludeFlags: treeIncludeFlags,
treeIncludeInherited: treeIncludeInherited,
}
}

Expand Down Expand Up @@ -166,6 +173,9 @@ func restoreReadonlyGlobals(g readonlyGlobals) {

inspectFormat = g.inspectFormat
inspectAllowPrivateNetwork = g.inspectAllowPrivateNetwork

treeIncludeFlags = g.treeIncludeFlags
treeIncludeInherited = g.treeIncludeInherited
readonlyGlobalsMu.Unlock()
}

Expand Down
35 changes: 24 additions & 11 deletions cmd/certkit/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@ import (
var treeCmd = &cobra.Command{
Use: "tree",
Short: "Display the full command tree",
Long: "Display every command, subcommand, and flag in a tree layout.",
Long: "Display the command and subcommand tree. Use --flags to include local flag details and --inherited to include inherited flag details.",
Args: cobra.NoArgs,
RunE: runTree,
}

var (
treeIncludeFlags bool
treeIncludeInherited bool
)

type printCommandTreeInput struct {
cmd *cobra.Command
prefix string
Expand All @@ -32,6 +37,8 @@ type commandTreeJSON struct {
}

func init() {
treeCmd.Flags().BoolVar(&treeIncludeFlags, "flags", false, "Include local flags in text tree output")
treeCmd.Flags().BoolVar(&treeIncludeInherited, "inherited", false, "Include inherited flags in text tree output")
rootCmd.AddCommand(treeCmd)
}

Expand Down Expand Up @@ -66,7 +73,8 @@ func initTreeSurface(cmd *cobra.Command) {
}

// printCommandTree recursively prints a command and its children with
// box-drawing connectors. Each command shows its flags indented beneath it.
// box-drawing connectors. Flag details are opt-in so the default tree stays
// focused on the command surface.
func printCommandTree(b *strings.Builder, in printCommandTreeInput) {
cmd := in.cmd
prefix := in.prefix
Expand All @@ -87,23 +95,28 @@ func printCommandTree(b *strings.Builder, in printCommandTreeInput) {
}
}

total := len(localFlags) + len(visible)
if len(inheritedFlags) > 0 {
total := len(visible)
if treeIncludeFlags {
total += len(localFlags)
}
if treeIncludeInherited && len(inheritedFlags) > 0 {
total++
}
idx := 0

// Print local flags.
for _, flag := range localFlags {
idx++
connector := "├── "
if idx == total {
connector = "└── "
if treeIncludeFlags {
for _, flag := range localFlags {
idx++
connector := "├── "
if idx == total {
connector = "└── "
}
fmt.Fprintf(b, "%s%s%s\n", prefix, connector, flag)
}
fmt.Fprintf(b, "%s%s%s\n", prefix, connector, flag)
}

if len(inheritedFlags) > 0 {
if treeIncludeInherited && len(inheritedFlags) > 0 {
idx++
connector := "├── "
if idx == total {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ require (
github.com/smallstep/pkcs7 v0.2.1
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
golang.org/x/crypto v0.48.0
golang.org/x/crypto v0.49.0
golang.org/x/sys v0.42.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.46.1
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
Expand Down Expand Up @@ -116,8 +116,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
Expand Down
Loading
Loading