mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:41:30 +00:00
b22cdb3405
CI run on commit03f0e08failed: ::error::gofmt would reformat these files (run 'gofmt -w' locally): internal/crypto/signer/file_driver.go Root cause: My Hotfix #13 (38f86bc, "go/path-injection in signer FileDriver") added an `assertCleanAbsPath` helper with a doc-comment numbered list. I used 3-space indent for the numbers (" 1. ...") and 6-space indent for continuation lines (" ...:") — gofmt's doc-comment formatter (Go 1.19+) standardized on 2-space indent for the bullet and 5-space for continuation, matching the position of text after "1. ". So all 5 list items + their continuations were off-by-one. This was undetectable in the sandbox during Hotfix #13's preparation because the Go toolchain wasn't installed — CLAUDE.md's pre-commit verification gate explicitly required `make verify` on workstation before push for that reason, and the commit body disclosed the gap. CI caught it. Fix: Run `gofmt -w internal/crypto/signer/file_driver.go`. Pure formatting — no code changes, no behavior change. 22 lines reformatted (11 add + 11 remove) — every list-item line's leading whitespace adjusted by 1 column. Confirmed `gofmt -d` is now clean. Verification (Go toolchain now wired in sandbox): Located the cached go1.25.10 toolchain at /sessions/.../.gomodcache/golang.org/toolchain@v0.0.1-go1.25.10.linux-arm64/bin Wired GOTOOLCHAIN=local + GOMODCACHE pointing at the cache, GOCACHE+GOTMPDIR on the root partition (larger free space). • gofmt -l internal/api/middleware/etag.go internal/crypto/signer/file_driver.go — clean • go vet ./internal/api/middleware/... ./internal/crypto/signer/... — exit 0 • go test -short -count=1 ./internal/api/middleware/... — ok 0.241s • go test -short -count=1 ./internal/crypto/signer/... — ok 1.431s • staticcheck ./internal/api/middleware/... ./internal/crypto/signer/... — zero findings • All 48 CI guards pass Ground-truth: origin/master tip03f0e08verified via GitHub API BEFORE commit. Local is at03f0e08(operator pushed Hotfix #14); this commit lands directly on top. Operator: the Go toolchain wiring is now established in the sandbox session, so future Go-side hotfixes will run full `go vet / go test / staticcheck` locally before commit (no more "manual syntax inspection — Go not available" disclaimers on Go-only changes). Falsifiable proof for next CI run: gofmt check should pass — no more "would reformat" output for file_driver.go.
401 lines
16 KiB
Go
401 lines
16 KiB
Go
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package signer
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// FileDriver materializes a Signer from a PEM-encoded private key on
|
|
// disk. This is the historical and current default behavior of the
|
|
// local issuer; FileDriver wraps that behavior without functional
|
|
// change so the local issuer can route every signing call through the
|
|
// Signer interface without changing what bytes land on disk.
|
|
//
|
|
// SECURITY: callers SHOULD set DirHardener and Marshaler to enforce
|
|
// the audited Bundle 9 hardening (key directory mode 0700 via
|
|
// keystore.ensureKeyDirSecure; marshal-with-zeroization via
|
|
// keymem.marshalPrivateKeyAndZeroize). When DirHardener is unset,
|
|
// Generate refuses to write — an explicit fail-loud signal rather
|
|
// than silently falling back to a permissive directory mode.
|
|
//
|
|
// Load does NOT call DirHardener (Load is read-only and the key may
|
|
// already exist in a directory whose mode the operator chose
|
|
// deliberately for their threat model). Load also does not call
|
|
// Marshaler (Load doesn't write anything).
|
|
type FileDriver struct {
|
|
// DirHardener, if set, is invoked on the directory containing a
|
|
// generated key file BEFORE the key is written. The local
|
|
// package wires this to keystore.ensureKeyDirSecure (via a closure
|
|
// — the helper stays package-private to preserve the audit trail
|
|
// in keystore.go's leading comment block). When nil, Generate
|
|
// returns an error.
|
|
DirHardener func(dir string) error
|
|
|
|
// Marshaler, if set, converts an *ecdsa.PrivateKey to the
|
|
// PEM-encoded byte slice that Generate will write to disk. The
|
|
// local package wires this to a wrapper around
|
|
// keymem.marshalPrivateKeyAndZeroize, ensuring the L-002
|
|
// heap-zeroization discipline applies to all keys generated
|
|
// through this driver. When nil, Generate falls back to a
|
|
// non-zeroizing marshal — acceptable for tests but NOT for
|
|
// production code paths.
|
|
Marshaler func(*ecdsa.PrivateKey) ([]byte, error)
|
|
|
|
// RSAMarshaler is the same shape as Marshaler but for RSA keys.
|
|
// Optional; if nil, Generate falls back to a non-zeroizing
|
|
// marshal. Provided for symmetry with Marshaler so the local
|
|
// issuer can plug in RSA-key-zeroization later without changing
|
|
// the FileDriver API.
|
|
RSAMarshaler func(*rsa.PrivateKey) ([]byte, error)
|
|
|
|
// GenerateOutPath, if set, is called with the generated key's
|
|
// algorithm and returns the destination path. When nil, Generate
|
|
// uses a default of <cwd>/ca-<alg>.key — fine for tests, NOT for
|
|
// production. The local package's NewConnector wires this to
|
|
// return the configured CAKeyPath.
|
|
GenerateOutPath func(alg Algorithm) (string, error)
|
|
|
|
// SafeRoot, if non-empty, restricts every Load + Generate path to
|
|
// the absolute filesystem subtree rooted at SafeRoot. Closes CodeQL
|
|
// go/path-injection (CWE-22 / CWE-23 / CWE-36): even though the
|
|
// driver's path inputs flow from operator-authenticated config
|
|
// (admin-only API surface), an admin compromise could otherwise
|
|
// write `/etc/passwd` or read `/root/.ssh/id_rsa` via the driver.
|
|
// SafeRoot bounds the blast radius.
|
|
//
|
|
// Validation semantics (validateSafePath):
|
|
//
|
|
// 1. The supplied path is cleaned (filepath.Clean) to collapse
|
|
// ./ and ../ sequences in their literal form.
|
|
// 2. If the cleaned path is relative, it's resolved against the
|
|
// current working directory via filepath.Abs.
|
|
// 3. If SafeRoot is set, the absolute path MUST be SafeRoot or
|
|
// a descendant. We use filepath.Rel + strings.HasPrefix on
|
|
// the cleaned absolute paths so symlink games (../ disguised
|
|
// as a symlink target) inside SafeRoot are bounded by
|
|
// SafeRoot's parent permissions, not by the validator.
|
|
//
|
|
// When SafeRoot is empty, the path is still cleaned + checked for
|
|
// the literal ".." element as a baseline defense-in-depth measure;
|
|
// callers that don't constrain to a root still get path-traversal
|
|
// rejection.
|
|
//
|
|
// Production wiring SHOULD set SafeRoot. The local-issuer config
|
|
// surface accepts CAKeyPath as an absolute path; cmd/server/main.go
|
|
// can derive SafeRoot from CERTCTL_CA_KEY_DIR (operator-trusted env
|
|
// var, never user-supplied) or from the parent of the configured
|
|
// path at issuer-registration time.
|
|
SafeRoot string
|
|
}
|
|
|
|
// Name implements Driver.
|
|
func (d *FileDriver) Name() string { return "file" }
|
|
|
|
// validateSafePath enforces the CWE-22 / CWE-23 / CWE-36 path-traversal
|
|
// defense documented on FileDriver.SafeRoot. Returns the cleaned
|
|
// absolute path on success; an explicit error on rejection. Rejects:
|
|
//
|
|
// - empty paths
|
|
// - paths whose cleaned form contains a literal ".." segment (defense
|
|
// against attacker-controlled fragments concatenated upstream — the
|
|
// filepath.Clean() before this check collapses any "..", so a
|
|
// remaining ".." is structural)
|
|
// - when SafeRoot is non-empty: any path whose cleaned absolute form
|
|
// is not SafeRoot or a descendant
|
|
//
|
|
// Apply in every Load + Generate path before any os.ReadFile /
|
|
// os.WriteFile call. CodeQL's taint tracker recognizes the validator
|
|
// in the same function as the sink and closes the alert.
|
|
func (d *FileDriver) validateSafePath(path string) (string, error) {
|
|
if path == "" {
|
|
return "", errors.New("path is empty")
|
|
}
|
|
cleaned := filepath.Clean(path)
|
|
// Reject any path whose cleaned form still contains a `..` element.
|
|
// filepath.Clean collapses `./` and `../` sequences relative to the
|
|
// path's structure, so a remaining `..` after Clean means the path
|
|
// is rooted (or attempts to escape) above whatever the caller
|
|
// intended.
|
|
for _, segment := range strings.Split(filepath.ToSlash(cleaned), "/") {
|
|
if segment == ".." {
|
|
return "", fmt.Errorf("path %q contains parent-directory segment", path)
|
|
}
|
|
}
|
|
abs, err := filepath.Abs(cleaned)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve absolute path %q: %w", path, err)
|
|
}
|
|
if d.SafeRoot != "" {
|
|
safeRoot, err := filepath.Abs(filepath.Clean(d.SafeRoot))
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve SafeRoot %q: %w", d.SafeRoot, err)
|
|
}
|
|
// Require the cleaned absolute path to be safeRoot itself or a
|
|
// strict descendant. The += string.Separator on safeRoot is
|
|
// load-bearing — without it a SafeRoot of "/var/lib/foo" would
|
|
// erroneously accept "/var/lib/foobar" as a prefix match.
|
|
safeRootSlash := safeRoot
|
|
if !strings.HasSuffix(safeRootSlash, string(filepath.Separator)) {
|
|
safeRootSlash += string(filepath.Separator)
|
|
}
|
|
if abs != safeRoot && !strings.HasPrefix(abs, safeRootSlash) {
|
|
return "", fmt.Errorf("path %q resolves outside SafeRoot %q", path, d.SafeRoot)
|
|
}
|
|
}
|
|
return abs, nil
|
|
}
|
|
|
|
// Load implements Driver. It reads the PEM file at path, decodes the
|
|
// first PEM block, parses it via the package's parsePrivateKey
|
|
// (which handles PKCS#1 / SEC 1 / PKCS#8), and wraps the resulting
|
|
// crypto.Signer.
|
|
//
|
|
// Errors are wrapped with the path so operators can grep their logs.
|
|
// No key bytes are logged — only the path and (on success) the
|
|
// inferred Algorithm.
|
|
func (d *FileDriver) Load(ctx context.Context, path string) (Signer, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, fmt.Errorf("signer.FileDriver.Load: %w", err)
|
|
}
|
|
// CWE-22 path-traversal defense — reject paths that escape SafeRoot
|
|
// (when set) OR contain literal ".." segments. validateSafePath
|
|
// does the structured rejection; the inline assertion below
|
|
// re-applies the canonical filepath.Rel + ".." rejection AT THE
|
|
// SINK so CodeQL's go/path-injection data-flow analyzer sees the
|
|
// sanitizer in-function (it doesn't reliably trace through
|
|
// function-call boundaries — Phase 6 commit 586308e shipped only
|
|
// validateSafePath and CodeQL alert #29 stayed open). Hotfix #13.
|
|
safePath, err := d.validateSafePath(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("signer.FileDriver.Load: %w", err)
|
|
}
|
|
if err := assertCleanAbsPath(safePath, d.SafeRoot); err != nil {
|
|
return nil, fmt.Errorf("signer.FileDriver.Load: %w", err)
|
|
}
|
|
|
|
pemBytes, err := os.ReadFile(safePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("signer.FileDriver.Load: read %q: %w", safePath, err)
|
|
}
|
|
block, _ := pem.Decode(pemBytes)
|
|
if block == nil {
|
|
return nil, fmt.Errorf("signer.FileDriver.Load: %q is not PEM", safePath)
|
|
}
|
|
key, err := parsePrivateKey(block)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("signer.FileDriver.Load: parse %q: %w", safePath, err)
|
|
}
|
|
wrapped, err := Wrap(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("signer.FileDriver.Load: wrap %q: %w", safePath, err)
|
|
}
|
|
return wrapped, nil
|
|
}
|
|
|
|
// Generate implements Driver. It generates a fresh private key with the
|
|
// requested algorithm, writes it to disk via the configured hooks, and
|
|
// returns the wrapped Signer plus the file path the caller can pass
|
|
// to a subsequent Load call.
|
|
//
|
|
// Refuses to write when DirHardener is unset — the production local
|
|
// package always wires the hardener; only tests are allowed to bypass
|
|
// it by constructing the FileDriver directly without calling
|
|
// NewProductionFileDriver.
|
|
func (d *FileDriver) Generate(ctx context.Context, alg Algorithm) (Signer, string, error) {
|
|
if d.DirHardener == nil {
|
|
return nil, "", errors.New("signer.FileDriver.Generate: DirHardener is required (set to a key-dir-permission validator) — refusing to write key with default umask")
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, "", fmt.Errorf("signer.FileDriver.Generate: %w", err)
|
|
}
|
|
|
|
// Resolve destination path before doing any expensive work.
|
|
pathFn := d.GenerateOutPath
|
|
if pathFn == nil {
|
|
pathFn = func(a Algorithm) (string, error) {
|
|
return fmt.Sprintf("ca-%s.key", a), nil
|
|
}
|
|
}
|
|
outPath, err := pathFn(alg)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("signer.FileDriver.Generate: resolve out path: %w", err)
|
|
}
|
|
|
|
// CWE-22 path-traversal defense — reject paths that escape SafeRoot
|
|
// (when set) OR contain literal ".." segments. validateSafePath
|
|
// does the structured rejection; the inline assertion below
|
|
// re-applies the canonical filepath.Rel + ".." rejection AT THE
|
|
// SINK so CodeQL's go/path-injection data-flow analyzer sees the
|
|
// sanitizer in-function (it doesn't reliably trace through
|
|
// function-call boundaries — Phase 6 commit 586308e shipped only
|
|
// validateSafePath and CodeQL alert #29 stayed open). Hotfix #13.
|
|
safeOut, err := d.validateSafePath(outPath)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("signer.FileDriver.Generate: %w", err)
|
|
}
|
|
if err := assertCleanAbsPath(safeOut, d.SafeRoot); err != nil {
|
|
return nil, "", fmt.Errorf("signer.FileDriver.Generate: %w", err)
|
|
}
|
|
|
|
// Harden the destination directory BEFORE generating the key. If
|
|
// the directory check fails we bail without touching cryptography.
|
|
if err := d.DirHardener(filepath.Dir(safeOut)); err != nil {
|
|
return nil, "", fmt.Errorf("signer.FileDriver.Generate: harden dir for %q: %w", safeOut, err)
|
|
}
|
|
|
|
// Generate the key for the requested algorithm.
|
|
var (
|
|
signerKey crypto.Signer
|
|
pemBytes []byte
|
|
)
|
|
switch alg {
|
|
case AlgorithmRSA2048, AlgorithmRSA3072, AlgorithmRSA4096:
|
|
bits := rsaBitsFor(alg)
|
|
rsaKey, gerr := rsa.GenerateKey(rand.Reader, bits)
|
|
if gerr != nil {
|
|
return nil, "", fmt.Errorf("signer.FileDriver.Generate: rsa keygen %d: %w", bits, gerr)
|
|
}
|
|
signerKey = rsaKey
|
|
if d.RSAMarshaler != nil {
|
|
pemBytes, err = d.RSAMarshaler(rsaKey)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("signer.FileDriver.Generate: RSAMarshaler: %w", err)
|
|
}
|
|
} else {
|
|
pemBytes = pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(rsaKey),
|
|
})
|
|
}
|
|
|
|
case AlgorithmECDSAP256, AlgorithmECDSAP384:
|
|
curve := ecCurveFor(alg)
|
|
ecKey, gerr := ecdsa.GenerateKey(curve, rand.Reader)
|
|
if gerr != nil {
|
|
return nil, "", fmt.Errorf("signer.FileDriver.Generate: ecdsa keygen %s: %w", curve.Params().Name, gerr)
|
|
}
|
|
signerKey = ecKey
|
|
if d.Marshaler != nil {
|
|
pemBytes, err = d.Marshaler(ecKey)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("signer.FileDriver.Generate: Marshaler: %w", err)
|
|
}
|
|
} else {
|
|
der, mErr := x509.MarshalECPrivateKey(ecKey)
|
|
if mErr != nil {
|
|
return nil, "", fmt.Errorf("signer.FileDriver.Generate: marshal ec key: %w", mErr)
|
|
}
|
|
pemBytes = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
|
|
}
|
|
|
|
default:
|
|
return nil, "", fmt.Errorf("signer.FileDriver.Generate: %w: %s", ErrUnsupportedAlgorithm, alg)
|
|
}
|
|
|
|
// Write 0o600 — owner-read-write only. Any read by group/other is
|
|
// a configuration regression; the dir 0700 above prevents
|
|
// enumeration of the file's existence.
|
|
if err := os.WriteFile(safeOut, pemBytes, 0o600); err != nil {
|
|
return nil, "", fmt.Errorf("signer.FileDriver.Generate: write %q: %w", safeOut, err)
|
|
}
|
|
|
|
wrapped, err := Wrap(signerKey)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("signer.FileDriver.Generate: wrap: %w", err)
|
|
}
|
|
return wrapped, safeOut, nil
|
|
}
|
|
|
|
// assertCleanAbsPath re-asserts CWE-22 path-injection invariants AT
|
|
// THE SINK (the function that's about to call os.ReadFile /
|
|
// os.WriteFile), not via validateSafePath in a sibling function.
|
|
// CodeQL's go/path-injection data-flow analyzer doesn't reliably
|
|
// trace sanitizers across function-call boundaries — it scopes its
|
|
// recognized-sanitizer pattern matching to the same function as the
|
|
// sink. So duplicating the check inline (filepath.Rel-style
|
|
// containment + IsAbs + clean assertions) is the
|
|
// belt-and-suspenders that closes alert #29.
|
|
//
|
|
// Invariants enforced:
|
|
//
|
|
// 1. path is non-empty.
|
|
// 2. path is absolute (the validateSafePath caller resolves
|
|
// filepath.Abs upstream; if we get a non-absolute path here,
|
|
// something downstream broke the contract).
|
|
// 3. path is filepath.Clean'd (no trailing separators, no double
|
|
// separators, no redundant "./").
|
|
// 4. path's slash-normalized segments contain no literal "..".
|
|
// 5. When safeRoot is non-empty: filepath.Rel(safeRoot, path)
|
|
// returns a non-"../*" result (path is at or below safeRoot in
|
|
// the resolved-absolute-path tree). filepath.Rel is the
|
|
// canonical CodeQL-recognized containment-check pattern.
|
|
//
|
|
// All of these are guaranteed by a successful validateSafePath
|
|
// upstream; this function exists purely so CodeQL sees the
|
|
// sanitizer pattern at the sink's own function-scope.
|
|
func assertCleanAbsPath(path, safeRoot string) error {
|
|
if path == "" {
|
|
return errors.New("sink path is empty")
|
|
}
|
|
if !filepath.IsAbs(path) {
|
|
return fmt.Errorf("sink path %q is not absolute", path)
|
|
}
|
|
if path != filepath.Clean(path) {
|
|
return fmt.Errorf("sink path %q is not Clean'd", path)
|
|
}
|
|
for _, seg := range strings.Split(filepath.ToSlash(path), "/") {
|
|
if seg == ".." {
|
|
return fmt.Errorf("sink path %q contains parent-directory segment", path)
|
|
}
|
|
}
|
|
if safeRoot != "" {
|
|
rootAbs, err := filepath.Abs(filepath.Clean(safeRoot))
|
|
if err != nil {
|
|
return fmt.Errorf("resolve SafeRoot %q: %w", safeRoot, err)
|
|
}
|
|
rel, err := filepath.Rel(rootAbs, path)
|
|
if err != nil {
|
|
return fmt.Errorf("sink path %q vs SafeRoot %q: %w", path, safeRoot, err)
|
|
}
|
|
// filepath.Rel returns ".." or "../..." when path is outside
|
|
// rootAbs. Reject any such result. "." or a non-dot-relative
|
|
// suffix is in-bounds.
|
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return fmt.Errorf("sink path %q resolves outside SafeRoot %q", path, safeRoot)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func rsaBitsFor(a Algorithm) int {
|
|
switch a {
|
|
case AlgorithmRSA3072:
|
|
return 3072
|
|
case AlgorithmRSA4096:
|
|
return 4096
|
|
default:
|
|
return 2048
|
|
}
|
|
}
|
|
|
|
func ecCurveFor(a Algorithm) elliptic.Curve {
|
|
if a == AlgorithmECDSAP384 {
|
|
return elliptic.P384()
|
|
}
|
|
return elliptic.P256()
|
|
}
|