cert

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 14, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

README

go-cert

GoDoc Go Report Card

Secure TLS certificate and CA pool loading with file permission and ownership validation for Go. Zero external dependencies — stdlib only.

Motivation

Loading TLS certificates from disk is straightforward in Go, but production deployments need more than just parsing PEM. Key files left group-readable, CA directories that are world-writable, or encrypted keys that silently fail at runtime are all common misconfiguration classes that surface as outages rather than clear errors.

go-cert adds a security-checked loading layer on top of the standard library. Every file is opened via openat(2) through a secured directory handle, then permission-checked via fstat on the open file descriptor — no TOCTOU gap between check and read. The entire directory hierarchy from / is audited for unsafe write bits. The default policy is strict — a nil SecurityOpts gives you secure defaults — so the zero-effort path is the secure path.

Features

  • Permission-checked file I/O — opens files via openat(2) through a cached os.Root, then validates permissions via fstat on the open fd. Group-writable and world-writable bits are always rejected (hardcoded security floor). Configurable permission mask and optional UID/GID ownership checks.
  • Directory hierarchy auditing — walks from / to the target directory via chained os.Root.OpenRoot calls, rejecting group-writable or world-writable path components (sticky-bit directories like /tmp are exempt). Directory handles are cached across operations.
  • CA pool accumulationStore accumulates CA certificates from individual files, directories (with optional recursion), and the system trust store. Finalize produces the *x509.CertPool and releases directory handles. All operations share a single cached security context with CA-appropriate defaults (Cert.Perm=0644).
  • Cert+key pair loading — the standalone LoadCertKey function handles full chains (leaf + intermediates), rejects encrypted keys with actionable openssl commands in the error message, and supports PKCS#1, PKCS#8, and EC key formats. Cert and key files are checked under independent policies (Cert.Perm=0644, Key.Perm=0600 by default), so a shared, world-readable cert is fine while the key stays owner-only.
  • Opinionated TLS configsServerConfig and ClientConfig return *tls.Config values with TLS 1.2 minimum and stdlib-managed cipher suites. mTLS is a one-argument toggle.
  • Structured errorsPermissionError and OwnershipError types work with errors.As for programmatic handling.
  • Cross-platform — full checks on Unix; graceful no-op on Windows.

API Overview

Security Policy

SecurityOpts controls file permission and ownership policy, split by file role via the nested FilePerm type:

type SecurityOpts struct {
    Cert   FilePerm               // policy for cert/CA files
    Key    FilePerm               // policy for private-key files (LoadCertKey only)
    Role   string                 // optional label for error messages
    Filter func(name string) bool // optional ReadDir filter
}

type FilePerm struct {
    Perm fs.FileMode
    Uid  int // -1 to skip ownership check
    Gid  int // -1 to skip ownership check
}

Store uses Cert (default Perm=0644) and ignores Key. LoadCertKey uses Cert for the cert file (default Perm=0644) and Key for the key file (default Perm=0600), each via its own ephemeral security context.

Passing nil applies strict role-appropriate defaults. Ownership is not checked by default (the common deployment pattern is a non-root service loading root-owned files) — callers that want ownership enforcement must set Uid/Gid explicitly (Uid=0 means "must be root"; use -1 to skip). DefaultSecurityOpts() returns an explicit copy. Group-writable and world-writable bits on files and directories are always rejected regardless of Perm.

// CA loading — Store uses Cert (default Perm=0644)
caStore := cert.NewStore(nil)

// Cert+key loading — cert@0644, key@0600 by default
tlsCert, err := cert.LoadCertKey("server.crt", "server.key", nil)

// Custom per-role policy
opts := &cert.SecurityOpts{
    Cert: cert.FilePerm{Perm: 0o644, Uid: 0, Gid: -1}, // must be root-owned
    Key:  cert.FilePerm{Perm: 0o400, Uid: 0, Gid: -1}, // read-only, root
}
tlsCert, err = cert.LoadCertKey("server.crt", "server.key", opts)
CA Pool

NewStore and NewSystemStore create a Store. Store.LoadCAFile and Store.LoadCADir accumulate certificates with permission validation. Store.Finalize returns a *Pool: an immutable, concurrency-safe value that wraps the assembled *x509.CertPool together with per-cert provenance metadata. Pool.Pool() exposes the *x509.CertPool for use with tls.Config, Pool.Source(cert) returns the file path a cert was loaded from, and Pool.Sources() / Pool.Count() mirror the Store introspection methods for post-Finalize use.

Finalize is idempotent and releases all internal directory handles; the returned *Pool remains valid after Store.Close.

store := cert.NewStore(nil)
if _, err := store.LoadCADir("/etc/ssl/certs", false); err != nil {
    return err
}
pool, err := store.Finalize()
if err != nil {
    return err
}
store.Close()

cfg := &tls.Config{RootCAs: pool.Pool()}

NewSystemStore seeds the store from the OS trust store; system CAs bypass permission checks since they are OS-managed.

Caching and Reload

A Store caches directory handles for its lifetime — each unique directory walked from / is opened once and reused for subsequent operations in the same hierarchy. Permission changes to a directory after its first access are not observed by later loads on the same Store. This is intentional: it bounds file-descriptor use and keeps reads consistent with the audit performed at first access. Operators who want permission changes to take effect should use a fresh Store per startup cycle (or per reload event) rather than expecting a long-lived Store to pick them up.

Certificate and Key Loading

LoadCertKey(certFile, keyFile, opts) loads a TLS certificate and private key pair. It checks the cert file under opts.Cert (default Perm=0644) and the key file under opts.Key (default Perm=0600) via two independent ephemeral security contexts. The cert file may contain a full chain. Encrypted keys are rejected with an error message containing the exact openssl command to decrypt them.

TLS Configuration

ServerConfig and ClientConfig return ready-to-use *tls.Config values. Both set MinVersion to TLS 1.2 and leave CipherSuites nil so Go's stdlib defaults (updated each release) govern cipher selection. Passing a non-nil clientCA to ServerConfig enables mutual TLS; passing a non-nil clientCert to ClientConfig does the same on the client side.

TLS Config Options

Both ServerConfig and ClientConfig accept a variadic list of TLSOption values applied after the opinionated defaults, so callers can override them without writing new constructors:

cfg := cert.ServerConfig(tlsCert, clientCA,
    cert.WithNextProtos("h2", "http/1.1"),
    cert.WithMinVersion(tls.VersionTLS13),
)

Available options:

  • WithMinVersion(v uint16) — override the TLS 1.2 floor.
  • WithNextProtos(protos ...string) — set ALPN protocols.
  • WithServerName(name string) — set SNI / verification name (client-side).
  • WithClientAuth(mode tls.ClientAuthType) — downgrade or change the server-side client-cert policy.
  • WithSPKIPins(pins ...[]byte) — pin peer certificates by raw SHA-256 SubjectPublicKeyInfo digests; fail-closed if no pin matches.
  • WithSessionTicketKeys(keys ...[32]byte) — rotate ticket keys across server instances.
Cert Expiry

CertExpiry(cert) returns the certificate in tls.Certificate with the earliest NotAfter along with the duration remaining until that cert expires (negative if already expired). Scans the leaf and all shipped intermediates; returns whichever expires first. Root CAs are not inspected because they live in the peer's trust store, not in tls.Certificate.Certificate.

c, remaining, err := cert.CertExpiry(tlsCert)
if err != nil {
    log.Fatal(err)
}
switch {
case remaining <= 0:
    log.Printf("EXPIRED: %s (expired %s ago)", c.Subject.CommonName, -remaining)
case remaining < 7*24*time.Hour:
    log.Printf("expiring soon: %s in %s", c.Subject.CommonName, remaining)
}

Requirements

  • Go 1.25 or later (for os.Root / openat-backed file access)
  • No external dependencies

License

See LICENSE for details.

Documentation

Overview

Package cert provides secure TLS certificate and CA pool loading with file permission and ownership validation for Go.

The package enforces strict Unix file permissions by default. A nil SecurityOpts always means strict defaults — the zero-effort path is the secure path. On Windows, permission and ownership checks are unavailable; use SecurityEnforced at runtime to detect the platform capability.

Policy is split by file role via the nested FilePerm type: SecurityOpts.Cert applies to CA files and to the cert file in LoadCertKey; SecurityOpts.Key applies only to the key file in LoadCertKey.

Store.Finalize returns a *Pool: an immutable, concurrency-safe value that wraps the assembled *x509.CertPool along with per-cert provenance metadata. Use Pool.Pool() to obtain the *x509.CertPool for use with tls.Config.

Caching and reload: a Store caches directory handles for its lifetime (inherited from the underlying safeio.Root). Permission changes to a directory after its first access are not observed by subsequent loads on the same Store. Use a fresh Store per startup cycle if you need to re-evaluate directory permissions.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func CertExpiry

func CertExpiry(tc tls.Certificate) (cert *x509.Certificate, remaining time.Duration, err error)

CertExpiry scans the leaf and every intermediate in tc.Certificate, returning the certificate with the earliest NotAfter along with the duration remaining until that cert expires. remaining is negative if the returned cert has already expired.

When tc.Leaf is populated, it is used in place of parsing tc.Certificate[0] — this saves one parse and avoids re-parsing what crypto/tls has already cached.

Root CAs are NOT inspected here — they live in the peer's trust store (x509.CertPool), not in tls.Certificate.Certificate. Only the leaf plus shipped intermediates are considered.

Returns an error when tc has no leaf at all (both Certificate is empty and Leaf is nil) or when any chain entry fails to parse.

Tie-break: when two chain entries share the same NotAfter, the one closer to the leaf (lower index in tc.Certificate) is returned.

func ClientConfig

func ClientConfig(rootCA *x509.CertPool, clientCert *tls.Certificate, opts ...TLSOption) *tls.Config

ClientConfig returns an opinionated *tls.Config for a TLS client.

  • rootCA is the CA pool for verifying the server's certificate. If nil, the system trust store is used (Go default behavior).
  • clientCert is the client's certificate for mTLS. If nil, no client certificate is presented.
  • opts are zero or more TLSOption values applied after the opinionated defaults, so they can override them (e.g., WithMinVersion(tls.VersionTLS13) replaces the default TLS 1.2 floor).

Opinionated defaults:

  • MinVersion: tls.VersionTLS12
  • CipherSuites: not set (Go stdlib defaults are secure and preferred)
  • InsecureSkipVerify: false (never set)

The caller may further customize the returned config directly or via TLSOption helpers such as WithServerName, WithNextProtos, and WithSPKIPins.

func LoadCertKey

func LoadCertKey(certFile, keyFile string, opts *SecurityOpts) (tls.Certificate, error)

LoadCertKey loads a TLS certificate and private key from the given files with permission validation. The cert file is checked against opts.Cert (default Perm=0o644); the key file is checked against opts.Key (default Perm=0o600). The cert file may contain a full chain (leaf + intermediates). Encrypted keys are rejected with actionable error messages.

On Windows, permission and ownership checks are no-ops; query SecurityEnforced to detect this at runtime.

If opts is nil, strict defaults are used (Cert: Perm=0o644, Uid=-1, Gid=-1; Key: Perm=0o600, Uid=-1, Gid=-1).

func SecurityEnforced

func SecurityEnforced() bool

SecurityEnforced reports whether permission and ownership checks are actually performed on this platform. Returns true on Unix and false on Windows. Callers that rely on the security guarantee should query this at startup and refuse to run (or log a clear warning) when it returns false.

func ServerConfig

func ServerConfig(cert tls.Certificate, clientCA *x509.CertPool, opts ...TLSOption) *tls.Config

ServerConfig returns an opinionated *tls.Config for a TLS server.

  • cert is the server's certificate (from LoadCertKey).
  • clientCA is the CA pool for verifying client certificates (for mTLS). If nil, client certificate verification is disabled. If non-nil, ClientAuth is set to RequireAndVerifyClientCert; callers may override via WithClientAuth.
  • opts are zero or more TLSOption values applied after the opinionated defaults, so they can override them (e.g., WithMinVersion(tls.VersionTLS13) replaces the default TLS 1.2 floor).

Opinionated defaults:

  • MinVersion: tls.VersionTLS12
  • CipherSuites: not set (Go stdlib defaults are secure and preferred)
  • InsecureSkipVerify: false (never set)

The caller may further customize the returned config directly or via TLSOption helpers such as WithNextProtos, WithClientAuth, WithSPKIPins, and WithSessionTicketKeys.

Types

type FilePerm

type FilePerm struct {
	// Perm is the maximum acceptable permission bits.
	// Any bits set beyond this mask cause rejection.
	Perm fs.FileMode

	// Uid is the expected file owner UID. Set to -1 to skip UID check.
	Uid int

	// Gid is the expected file owner GID. Set to -1 to skip GID check.
	Gid int
}

FilePerm is a permission and ownership policy for a single file role. Use -1 for Uid or Gid to skip that ownership check.

Each field has its own zero-value fallback: Perm=0 becomes the role-appropriate default, and Uid=0/Gid=0 become -1 (skip). Because 0 is also the legitimate UID/GID of root, this package does not support "require root ownership" via FilePerm; callers who need that must perform their own out-of-band check. Use -1 explicitly to skip an ownership check.

type OwnershipError

type OwnershipError = safeio.OwnershipError

OwnershipError is returned when a file fails ownership validation. Callers can use errors.As to distinguish ownership failures from I/O or parse errors.

type PermissionError

type PermissionError = safeio.PermissionError

PermissionError is returned when a file fails permission validation. Callers can use errors.As to distinguish permission failures from I/O or parse errors.

type Pool

type Pool struct {
	// contains filtered or unexported fields
}

Pool is the immutable result of a finalized Store. It holds the assembled *x509.CertPool plus metadata for provenance lookups.

A Pool is produced by Store.Finalize and remains valid after the originating Store is closed. Pool holds no file descriptors.

A Pool is safe for concurrent readers. Pool retains its reference to the *x509.CertPool; concurrent readers may call Pool() multiple times.

func (*Pool) Count

func (p *Pool) Count() int

Count returns the number of CA certificates loaded by the Store that produced this Pool. System trust-store certs (if any) are NOT counted — use Pool() and walk its contents for that.

func (*Pool) Pool

func (p *Pool) Pool() *x509.CertPool

Pool returns the assembled *x509.CertPool for use with tls.Config. The returned value is the same *x509.CertPool instance this Pool holds; the caller takes shared read-only ownership. Mutating it affects any future Source lookups on this Pool and any other reader of the same Pool. Do not call AddCert on the returned pool.

func (*Pool) Source

func (p *Pool) Source(cert *x509.Certificate) string

Source returns the source description for cert — typically the file path or bundle it was loaded from. Returns "" if cert is nil, if cert is not in this Pool, or if cert came from the system trust store (seeded by NewSystemStore and therefore not tracked for provenance).

func (*Pool) Sources

func (p *Pool) Sources() []string

Sources returns the human-readable summary strings accumulated during Store loading. Order matches the order of successful Load calls on the originating Store. The returned slice is a copy; mutating it does not affect the Pool.

type SecurityOpts

type SecurityOpts struct {
	// Cert is the policy for cert/CA files.
	Cert FilePerm

	// Key is the policy for private key files (LoadCertKey only).
	Key FilePerm

	// Role is an optional label included in error messages. When empty,
	// the package supplies a role-appropriate label ("ca", "cert", "key")
	// based on which file is being checked.
	Role string

	// Filter, if non-nil, is applied to directory walks in Store
	// operations. The Store sets its own default when this is nil.
	Filter func(name string) bool
}

SecurityOpts controls per-file permission and ownership policy. A nil value means DefaultSecurityOpts().

Cert applies to CA files (LoadCAFile, LoadCADir) and to the cert file in LoadCertKey. Key applies only to the key file in LoadCertKey and is ignored by Store operations.

Group-writable and world-writable bits on files and directories are always rejected regardless of Perm (hardcoded security floor).

func DefaultSecurityOpts

func DefaultSecurityOpts() *SecurityOpts

DefaultSecurityOpts returns a SecurityOpts with strict role-appropriate defaults:

  • Cert: Perm=0o644, Uid=-1, Gid=-1
  • Key: Perm=0o600, Uid=-1, Gid=-1

Ownership is not checked by default because the common deployment pattern is a non-root service loading root-owned cert files. Callers that want ownership enforcement must set Uid/Gid explicitly. Permission-bit checks (and the hardcoded group/world-writable floor) still apply and provide the security floor.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store provides secure loading and accumulation of CA certificates into a *Pool. It wraps a safeio.Root with a permission policy appropriate for CA files (default Perm=0644).

For loading cert+key pairs, use the standalone LoadCertKey function which applies its own stricter permission policy (default Perm=0600).

On Windows, permission and ownership checks are no-ops; query SecurityEnforced to detect this at runtime.

Store is safe for concurrent use; a single mutex serializes LoadCAFile, LoadCADir, Finalize, Close, Count, and Sources. The *Pool it produces via Finalize is safe for concurrent readers.

Store inherits the directory-handle caching of safeio.Root: permission changes to a directory after its first access are not observed by subsequent loads on the same Store. Use a fresh Store per startup cycle if you need to re-evaluate directory permissions.

func NewStore

func NewStore(opts *SecurityOpts) *Store

NewStore creates a Store with the given security policy. If opts is nil, strict defaults are used (Cert: Perm=0644, Uid=-1, Gid=-1). Store uses only opts.Cert; opts.Key is ignored.

The Store's safeio.Root filters directory walks by certificate extension (.pem, .crt, .cer) automatically.

func NewSystemStore

func NewSystemStore(opts *SecurityOpts) (*Store, error)

NewSystemStore creates a Store initialized with the system trust store (x509.SystemCertPool). System CA files are NOT subject to permission checks (they are managed by the OS). Only subsequently loaded files are checked. If opts is nil, strict defaults are used.

func (*Store) Close

func (s *Store) Close() error

Close closes all cached directory handles and invalidates the store. After Close, load and read methods on the Store return errors. Any *Pool previously returned by Finalize remains valid (a Pool holds no file descriptors).

Close is idempotent and safe to call after Finalize.

func (*Store) Count

func (s *Store) Count() (int, error)

Count returns the number of CA certificates loaded so far (not counting system CAs if NewSystemStore was used).

Count continues to work after Finalize; it returns an error only after the Store has been Close'd without being finalized.

func (*Store) Finalize

func (s *Store) Finalize() (*Pool, error)

Finalize assembles the accumulated CA certificates into an *x509.CertPool, wraps it in a *Pool with provenance metadata, caches the result, and releases all internal directory handles. Subsequent calls return the same cached *Pool.

Callers that previously used the returned *x509.CertPool directly should call Pool.Pool() on the returned value.

After Finalize, LoadCAFile and LoadCADir return errors. Count and Sources continue to work (they read from the preserved state).

func (*Store) LoadCADir

func (s *Store) LoadCADir(dir string, recurse bool) (int, error)

LoadCADir loads PEM-encoded CA certificates from all files in the given directory. If recurse is true, subdirectories are walked. Files are filtered by extension (.pem, .crt, .cer) before opening. Files that are not valid PEM are silently skipped. Files that fail permission checks stop the walk on the first error. Duplicate certificates (same DER bytes) are counted once; the first-seen file path is recorded as the source.

On error, previously loaded certificates remain in the Store and will be included if Finalize is subsequently called. Callers who require all-or-nothing semantics should call Close() on the Store after a LoadCADir error.

Returns the total number of certificates added and any error.

LoadCADir holds the Store's internal mutex for the duration of the directory walk. Concurrent calls to any Store method (including Count and Sources) on the same Store will block until the walk completes. Since LoadCADir is typically called at startup, this is usually fine; callers loading from directories with many large files or across slow filesystems should be aware.

func (*Store) LoadCAFile

func (s *Store) LoadCAFile(path string) (int, error)

LoadCAFile loads one or more PEM-encoded CA certificates from a single file (which may be a bundle containing multiple certs). Returns the number of certificates added and any error.

Returns an error if the file contains no CERTIFICATE blocks OR if every certificate is already present in the Store (duplicates are detected by sha256 of the DER bytes; the first-seen source is kept).

func (*Store) SecurityEnforced

func (s *Store) SecurityEnforced() bool

SecurityEnforced reports whether permission and ownership checks are actually performed on this platform. Returns true on Unix and false on Windows.

Unguarded: reads only the immutable build-time platform constant.

func (*Store) Sources

func (s *Store) Sources() ([]string, error)

Sources returns a human-readable summary of where certs were loaded from, suitable for startup logging.

Sources continues to work after Finalize; it returns an error only after the Store has been Close'd without being finalized.

type TLSOption

type TLSOption func(*tls.Config)

TLSOption mutates a *tls.Config after opinionated defaults have been applied. It lets callers layer small, composable overrides onto the configs returned by ServerConfig and ClientConfig.

func WithClientAuth

func WithClientAuth(mode tls.ClientAuthType) TLSOption

WithClientAuth sets cfg.ClientAuth. This is a server-side option. ServerConfig sets ClientAuth to tls.RequireAndVerifyClientCert when a non-nil clientCA is provided; this option lets callers downgrade to tls.VerifyClientCertIfGiven, tls.RequestClientCert, or similar.

func WithMinVersion

func WithMinVersion(v uint16) TLSOption

WithMinVersion sets cfg.MinVersion to v. No clamping is performed; callers are responsible for passing a meaningful value. Common values: tls.VersionTLS12, tls.VersionTLS13.

func WithNextProtos

func WithNextProtos(protos ...string) TLSOption

WithNextProtos sets cfg.NextProtos for ALPN negotiation. A typical use is WithNextProtos("h2", "http/1.1") to advertise HTTP/2 with a fallback to HTTP/1.1.

func WithSPKIPins

func WithSPKIPins(pins ...[]byte) TLSOption

WithSPKIPins installs a cfg.VerifyConnection callback that rejects the handshake unless the leaf peer certificate's SubjectPublicKeyInfo SHA-256 digest matches one of the provided pins. Pinning is leaf-only: intermediates and roots in the chain are not considered, so a compromised intermediate cannot bypass the pin by issuing a new leaf. Each pin must be a raw 32-byte SHA-256 digest; pins of any other length are skipped.

Error behavior:

  • If all supplied pins are malformed (none is 32 bytes), every handshake is rejected with "cert: no valid SPKI pins configured".
  • If the handshake presents no peer certificates, it is rejected with "cert: no peer certificates presented".
  • If the leaf's SPKI hash does not match any configured pin, the handshake is rejected with "cert: no matching SPKI pin".

If cfg.VerifyConnection is already set when this option is applied, the existing callback is chained and invoked only on a successful pin match; pin rejection short-circuits and the previous callback is not called.

func WithServerName

func WithServerName(name string) TLSOption

WithServerName sets cfg.ServerName. This is primarily a client-side option used for SNI and certificate verification. On a server config it is a no-op for most code paths but remains harmless.

func WithSessionTicketKeys

func WithSessionTicketKeys(keys ...[32]byte) TLSOption

WithSessionTicketKeys installs the given session ticket keys via cfg.SetSessionTicketKeys. This is a server-side option used to rotate ticket keys across instances for mTLS fleets. If len(keys) == 0 the option is a no-op.

Directories

Path Synopsis
internal
safeio
Package safeio provides secure, openat-backed file reading with permission and ownership validation.
Package safeio provides secure, openat-backed file reading with permission and ownership validation.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL