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
4 changes: 2 additions & 2 deletions .claude/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Certificate/key processing, in-memory storage, and persistence. Used by both CLI
- `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`.
- `helpers.go` — `GetKeyType`, `HasBinaryExtension`, `FormatCN`, `SanitizeFileName`, `FormatIPAddresses`.
- `helpers.go` — `GetKeyType`, `HasBinaryExtension`, `FormatCN`, `SanitizeFileName`, `ValidateK8sSecretName`, `FormatIPAddresses`.
- `container.go` — `ContainerContents` struct and `ParseContainerData()`: extracts leaf cert, key, and extra certs from PKCS#12, JKS, PKCS#7, PEM, or DER input. Shared by CLI and WASM.
- `sqlite.go` — SQLite persistence (`//go:build !js`). `SaveToSQLite(store, path)` and `LoadFromSQLite(store, path)` for `--save-db`/`--load-db` flags. Self-contained: opens in-memory SQLite, transfers data, uses `VACUUM INTO` to write.
- `sqlite_js.go` — `js/wasm` persistence stub. Exposes the same `SaveToSQLite()` / `LoadFromSQLite()` symbols as `sqlite.go`, but returns an unsupported error so mixed-target builds and workspace analysis stay consistent without a native SQLite driver.
Expand All @@ -46,7 +46,7 @@ CLI business logic and file I/O. Delegates to `internal/certstore/` for processi

- `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).
- `bundleconfig.go` — YAML config parsing. Supports `defaultSubject` inheritance.
- `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`.
- `format.go` — Shared formatting helpers. `CertAnnotation()` for scan summary annotations (expired/untrusted counts).
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Use bundle folder name as Kubernetes secret `metadata.name` instead of the CN-derived prefix, so the secret name matches the export directory ([#178])
- Validate `bundleName` in bundle config YAML against DNS-1123 rules at load time; invalid names now produce a fatal error with the file path and line number ([#178])
- Deduplicate extension block formatting between `connect --verbose` and the shared internal formatter to prevent drift in extension flag rendering ([`30d67f8`])
- Avoid loading system roots during the pre-verification AIA bundle walk in `verify`, and include intermediates from all verified connect chains when computing `trust_anchors` ([#172])
- Preserve real pre-bundle AIA incomplete diagnostics in `verify`, resolve convert/export issuer chains with signature-aware issuer selection, and fail closed when certstore validation is ambiguous across same-SKI renewals ([#172])
Expand Down Expand Up @@ -1118,6 +1120,7 @@ Initial release.
[#169]: https://github.com/sensiblebit/certkit/pull/169
[#171]: https://github.com/sensiblebit/certkit/pull/171
[#172]: https://github.com/sensiblebit/certkit/pull/172
[#178]: https://github.com/sensiblebit/certkit/pull/178
[#73]: https://github.com/sensiblebit/certkit/pull/73
[#64]: https://github.com/sensiblebit/certkit/pull/64
[#63]: https://github.com/sensiblebit/certkit/pull/63
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ require (
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
modernc.org/sqlite v1.47.0
software.sslmate.com/src/go-pkcs12 v0.7.0
Comment thread
danielewood marked this conversation as resolved.
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
Expand Down
86 changes: 86 additions & 0 deletions internal/bundleconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package internal
import (
"fmt"

"github.com/sensiblebit/certkit/internal/certstore"
"gopkg.in/yaml.v3"
)

Expand Down Expand Up @@ -52,6 +53,9 @@ func LoadBundleConfigs(path string) ([]BundleConfig, error) {
}
}
}
if err := validateBundleNames(path, data); err != nil {
return nil, err
}
return yamlConfig.Bundles, nil
}

Expand All @@ -60,5 +64,87 @@ func LoadBundleConfigs(path string) ([]BundleConfig, error) {
if err := yaml.Unmarshal(data, &configs); err != nil {
return nil, fmt.Errorf("parsing bundle config %s: %w", path, err)
}
if err := validateBundleNamesOldFormat(path, data); err != nil {
return nil, err
}
return configs, nil
}

// validateBundleNames walks the YAML node tree for the new format (map with
// "bundles" key) and validates each bundleName against DNS-1123 rules. Errors
// include the file path and line number of the offending value.
func validateBundleNames(path string, data []byte) error {
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return fmt.Errorf("parsing %s for validation: %w", path, err)
}
if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
return nil
}
root := doc.Content[0]
if root.Kind != yaml.MappingNode {
return nil
}
// Find the "bundles" key
for i := 0; i+1 < len(root.Content); i += 2 {
if root.Content[i].Value != "bundles" {
continue
}
bundlesNode := root.Content[i+1]
if bundlesNode.Kind != yaml.SequenceNode {
break
}
for _, entry := range bundlesNode.Content {
if err := validateBundleNameNode(path, entry); err != nil {
return err
}
}
}
return nil
}

// validateBundleNamesOldFormat walks the YAML node tree for the old format
// (bare array of bundles) and validates each bundleName.
func validateBundleNamesOldFormat(path string, data []byte) error {
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return fmt.Errorf("parsing %s for validation: %w", path, err)
}
if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
return nil
}
root := doc.Content[0]
if root.Kind != yaml.SequenceNode {
return nil
}
for _, entry := range root.Content {
if err := validateBundleNameNode(path, entry); err != nil {
return err
}
}
return nil
}

// validateBundleNameNode checks a single bundle mapping node for a valid
// bundleName value.
func validateBundleNameNode(path string, entry *yaml.Node) error {
if entry.Kind != yaml.MappingNode {
return nil
}
for j := 0; j+1 < len(entry.Content); j += 2 {
if entry.Content[j].Value != "bundleName" {
continue
}
valNode := entry.Content[j+1]
name := valNode.Value
// Skip null/empty — these use the CN-derived name instead.
if name == "" || valNode.Tag == "!!null" {
break
}
if err := certstore.ValidateK8sSecretName(name); err != nil {
return fmt.Errorf("%s:%d: %w", path, valNode.Line, err)
}
break
}
return nil
}
102 changes: 102 additions & 0 deletions internal/bundleconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,108 @@ bundles:
}
}

func TestLoadBundleConfigs_InvalidBundleNameDNS1123(t *testing.T) {
// WHY: bundleName is used as the Kubernetes secret metadata.name, which must
// be a valid DNS-1123 subdomain. Invalid names must be rejected at config load
// time with a line-number reference so the user can fix the YAML.
t.Parallel()

tests := []struct {
name string
yaml string
wantErr string
}{
{
name: "uppercase",
yaml: `bundles:
- commonNames: ["example.com"]
bundleName: "MyBundle"
`,
wantErr: `:3:`,
},
{
name: "underscores",
yaml: `bundles:
- commonNames: ["example.com"]
bundleName: "my_bundle"
`,
wantErr: `:3:`,
},
{
name: "dots",
yaml: `bundles:
- commonNames: ["example.com"]
bundleName: "my.bundle"
`,
wantErr: `:3:`,
},
{
name: "leading hyphen",
yaml: `bundles:
- commonNames: ["example.com"]
bundleName: "-my-bundle"
`,
wantErr: `:3:`,
},
{
name: "valid name accepted",
yaml: `bundles:
- commonNames: ["example.com"]
bundleName: "my-bundle"
`,
},
{
name: "null name accepted",
yaml: `bundles:
- commonNames: ["example.com"]
bundleName: null
`,
},
{
name: "empty string accepted",
yaml: `bundles:
- commonNames: ["example.com"]
bundleName: ""
`,
},
{
name: "old format invalid",
yaml: `- commonNames: ["example.com"]
bundleName: "BAD_NAME"
`,
wantErr: `:2:`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "bundles.yaml")
if err := os.WriteFile(path, []byte(tt.yaml), 0600); err != nil {
t.Fatalf("write config: %v", err)
}

_, err := LoadBundleConfigs(path)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr)
}
if !strings.Contains(err.Error(), "DNS-1123") {
t.Errorf("error %q should mention DNS-1123", err.Error())
}
})
}
}

func TestLoadBundleConfigs_EmptyBundles(t *testing.T) {
// WHY: An empty bundles array with a defaultSubject falls through to old-format parsing, which should fail; this guards against silently accepting a misconfigured file.
t.Parallel()
Expand Down
2 changes: 1 addition & 1 deletion internal/certstore/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ func ExportMatchedBundles(ctx context.Context, input ExportMatchedBundleInput) e
KeyType: keyRec.KeyType,
BitLength: keyRec.BitLength,
Prefix: prefix,
SecretName: strings.TrimPrefix(prefix, "_."),
SecretName: folder,
CSRSubject: input.CSRSubject,
Comment thread
danielewood marked this conversation as resolved.
Comment thread
danielewood marked this conversation as resolved.
Comment thread
danielewood marked this conversation as resolved.
P12Password: input.P12Password,
EncryptKey: input.EncryptKey,
Expand Down
15 changes: 15 additions & 0 deletions internal/certstore/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,21 @@ func TestExportMatchedBundles(t *testing.T) {
t.Errorf("folder = %q, want %q", call.folder, "my-bundle")
}

// K8s secret metadata.name must match the folder name
for _, f := range call.files {
if !strings.HasSuffix(f.Name, ".k8s.yaml") {
continue
}
var secret K8sSecret
if err := yaml.Unmarshal(f.Data, &secret); err != nil {
t.Fatalf("k8s.yaml: invalid YAML: %v", err)
}
if secret.Metadata.Name != "my-bundle" {
t.Errorf("k8s.yaml metadata.name = %q, want %q", secret.Metadata.Name, "my-bundle")
}
break
}

// GenerateBundleFiles always produces: .pem, .chain.pem, .fullchain.pem,
// .key, .p12, .k8s.yaml, .json, .yaml, .csr, .csr.json (10 files).
// With a custom root, .root.pem is also present (11 total).
Expand Down
20 changes: 20 additions & 0 deletions internal/certstore/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"path/filepath"
"regexp"
"strings"
)

Expand Down Expand Up @@ -136,6 +137,25 @@ func SanitizeFileName(name string) string {
return unsafeFileNameReplacer.Replace(name)
}

// dns1123Pattern matches a valid DNS-1123 label: lowercase alphanumeric,
// hyphens allowed internally, max 63 characters.
var dns1123Pattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)

// errSecretNameInvalid is returned when a folder name cannot be used as a
// Kubernetes secret metadata.name because it violates DNS-1123 rules.
var errSecretNameInvalid = errors.New("invalid Kubernetes secret name: must be a lowercase DNS-1123 subdomain (lowercase alphanumeric and hyphens, must start and end with alphanumeric)")

// ValidateK8sSecretName checks that name is a valid DNS-1123 subdomain label
// suitable for Kubernetes metadata.name. Returns errSecretNameInvalid when
// the name contains uppercase letters, underscores, dots, or other characters
// that Kubernetes rejects.
func ValidateK8sSecretName(name string) error {
if name == "" || !dns1123Pattern.MatchString(name) {
return fmt.Errorf("%w: %q", errSecretNameInvalid, name)
}
return nil
}

// SanitizeBundleFolder returns a safe folder name for bundle output.
// Rejects empty or dot-path names after sanitization.
func SanitizeBundleFolder(name string) (string, error) {
Expand Down
Loading