Compare commits

...

48 Commits

Author SHA1 Message Date
fb1b1ffcad Update CHANGELOG for v1.15.4. 2025-11-19 02:57:40 -08:00
7bb6973341 QoL for CSR generation. 2025-11-19 02:57:26 -08:00
8e997bda34 Update CHANGELOG for v1.15.3. 2025-11-19 02:43:47 -08:00
d76db4a947 Minor bug fixes. 2025-11-19 02:43:25 -08:00
7e36a828d4 Update CHANGELOG for v1.15.2. 2025-11-19 02:20:36 -08:00
8eaca580be Minor bug fixes. 2025-11-19 02:20:21 -08:00
fd31e31afa Update CHANGELOG for v1.15.1. 2025-11-19 01:47:48 -08:00
7426988ae4 linter fixes. 2025-11-19 01:47:42 -08:00
b17fad4334 Update CHANGELOG for v1.15.0. 2025-11-19 01:36:45 -08:00
154d5a6c2e Major refactoring.
+ Many lib functions have been split out into separate packages.
+ Adding cert/key generation tooling.
+ Add new time.Duration parser.
2025-11-19 01:35:37 -08:00
90a48a1890 Add unit tests for keymatch. 2025-11-19 00:32:39 -08:00
245cf78ebb certlib/hosts: update doc string to describe valid targets. 2025-11-18 23:54:50 -08:00
f4851af42f Update CHANGELOG for v1.14.7. 2025-11-18 23:45:45 -08:00
bf29d214c5 lib: add base64 hex encoding; linter fixes. 2025-11-18 23:45:21 -08:00
ff34eb4eff cmd/ca-signed: clean up the codebase 2025-11-18 23:01:58 -08:00
7f3f513bdd Update CHANGELOG for v1.14.6. 2025-11-18 21:11:24 -08:00
786f116f54 certlib: move tlskeypair functions into certlib. 2025-11-18 21:10:48 -08:00
89aaa969b8 Update CHANGELOG for v1.14.5. 2025-11-18 20:56:56 -08:00
f5917ac6fc verify/verify.go: fix nil point deref 2025-11-18 20:55:41 -08:00
3e80e46c17 Update CHANGELOG for v1.14.4. 2025-11-18 20:22:30 -08:00
3c1d92db6b cmd: refactor cert utils into certlib 2025-11-18 20:21:00 -08:00
25a562865c cmd/kgz: linter fixes. 2025-11-18 18:55:58 -08:00
e30e3e9b75 Update CHANGELOG for v1.14.3, 2025-11-18 18:36:47 -08:00
57672c8f78 cmd/certdump: refactor certdump into reusable library package 2025-11-18 18:34:57 -08:00
17e999754b cmd/kgz: add extended metadata support to kgz compression 2025-11-18 18:34:39 -08:00
c4c9abe310 Update CHANGELOG for v1.14.2. 2025-11-18 17:28:49 -08:00
b714c75a43 Add tooling to enable strict TLS. 2025-11-18 17:25:49 -08:00
3f92963c74 Update CHANGELOG for v1.14.1. 2025-11-18 16:12:58 -08:00
51f6d7c74d Add missing Dockerfile. 2025-11-18 16:12:22 -08:00
67bf26c5da Update CHANGELOG to v1.14.0. 2025-11-18 16:09:38 -08:00
62c3db88ef Add proxy-aware dialing functions, and convert cmd/... tooling over. 2025-11-18 16:09:19 -08:00
bb7749efd1 Update CHANGELOG for v1.13.6. 2025-11-18 14:13:10 -08:00
a3a8115279 goreleaser: remove gitea parts. 2025-11-18 14:12:58 -08:00
8ca8538268 Update CHANGELOG for v1.13.5. 2025-11-18 13:42:44 -08:00
155c49cc5e build: gitea is pulling the repo information from github. 2025-11-18 13:42:28 -08:00
dda9fd9f07 Bump CHANGELOG for v1.13.4. 2025-11-18 13:37:12 -08:00
c251c1e1b5 fix up goreleaser config. 2025-11-18 13:35:47 -08:00
6eb533f79b Tweak goreleaser config. 2025-11-18 13:23:20 -08:00
ea5ffa4828 Update CHANGELOG for v1.13.3. 2025-11-18 13:11:29 -08:00
aa96e47112 update goreleaser config 2025-11-18 13:01:23 -08:00
d34a417dce fixups 2025-11-18 12:37:15 -08:00
d11e0cf9f9 lib: add byte slice output for HexEncode 2025-11-18 11:58:43 -08:00
aad7d68599 cmd/ski: update display mode 2025-11-18 11:46:58 -08:00
4560868688 cmd: switch programs over to certlib.Fetcher. 2025-11-18 11:08:17 -08:00
8d5406256f cmd/certdump: use certlib.Fetcher. 2025-11-17 19:49:42 -08:00
9280e846fa certlib: add Fetcher
Fetcher is an interface and set of functions for retrieving a
certificate (or chain of certificates) from a spec. It will
determine whether the certificate spec is a file or a server,
and fetch accordingly.
2025-11-17 19:48:57 -08:00
0a71661901 CHANGELOG: bump to v1.13.2. 2025-11-17 15:50:51 -08:00
804f53d27d Refactor bundling into separate package. 2025-11-17 15:08:10 -08:00
52 changed files with 4314 additions and 1879 deletions

35
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch: {}
permissions:
contents: write
jobs:
goreleaser:
name: GoReleaser
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
cache: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -1,2 +1,5 @@
.idea
cmd/cert-bundler/testdata/pkg/*
# Added by goreleaser init:
dist/
cmd/cert-bundler/testdata/bundle/

View File

@@ -101,7 +101,7 @@ linters:
- loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap)
- makezero # finds slice declarations with non-zero initial length
- mirror # reports wrong mirror patterns of bytes/strings usage
- mnd # detects magic numbers
# - mnd # detects magic numbers
- modernize # suggests simplifications to Go code, using modern language and library features
- musttag # enforces field tags in (un)marshaled structs
- nakedret # finds naked returns in functions greater than a specified function length
@@ -247,11 +247,12 @@ linters:
# Default: false
check-type-assertions: true
exclude-functions:
- (*git.wntrmute.dev/kyle/goutils/sbuf.Buffer).Write
- (*git.wntrmute.dev/kyle/goutils/dbg.DebugPrinter).Write
- git.wntrmute.dev/kyle/goutils/lib.Warn
- git.wntrmute.dev/kyle/goutils/lib.Warnx
- git.wntrmute.dev/kyle/goutils/lib.Err
- git.wntrmute.dev/kyle/goutils/lib.Errx
- (*git.wntrmute.dev/kyle/goutils/sbuf.Buffer).Write
exhaustive:
# Program elements to check for exhaustiveness.
@@ -390,6 +391,9 @@ linters:
- 3
- 4
- 8
- 24
- 30
- 365
nakedret:
# Make an issue if func has more lines of code than this setting, and it has naked returns.

445
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,445 @@
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
# The lines below are called `modelines`. See `:help modeline`
# Feel free to remove those if you don't want/need to use them.
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
# you may remove this if you don't need go generate
- go generate ./...
builds:
- id: atping
main: ./cmd/atping/main.go
binary: atping
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: ca-signed
main: ./cmd/ca-signed/main.go
binary: ca-signed
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: cert-bundler
main: ./cmd/cert-bundler/main.go
binary: cert-bundler
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: cert-revcheck
main: ./cmd/cert-revcheck/main.go
binary: cert-revcheck
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: certchain
main: ./cmd/certchain/main.go
binary: certchain
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: certdump
main: ./cmd/certdump/main.go
binary: certdump
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: certexpiry
main: ./cmd/certexpiry/main.go
binary: certexpiry
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: certser
main: ./cmd/certser/main.go
binary: certser
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: certverify
main: ./cmd/certverify/main.go
binary: certverify
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: clustersh
main: ./cmd/clustersh/main.go
binary: clustersh
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: cruntar
main: ./cmd/cruntar/main.go
binary: cruntar
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: csrpubdump
main: ./cmd/csrpubdump/main.go
binary: csrpubdump
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: data_sync
main: ./cmd/data_sync/main.go
binary: data_sync
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: diskimg
main: ./cmd/diskimg/main.go
binary: diskimg
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: dumpbytes
main: ./cmd/dumpbytes/main.go
binary: dumpbytes
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: eig
main: ./cmd/eig/main.go
binary: eig
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: fragment
main: ./cmd/fragment/main.go
binary: fragment
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: host
main: ./cmd/host/main.go
binary: host
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: jlp
main: ./cmd/jlp/main.go
binary: jlp
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: kgz
main: ./cmd/kgz/main.go
binary: kgz
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: minmax
main: ./cmd/minmax/main.go
binary: minmax
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: parts
main: ./cmd/parts/main.go
binary: parts
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: pem2bin
main: ./cmd/pem2bin/main.go
binary: pem2bin
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: pembody
main: ./cmd/pembody/main.go
binary: pembody
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: pemit
main: ./cmd/pemit/main.go
binary: pemit
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: readchain
main: ./cmd/readchain/main.go
binary: readchain
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: renfnv
main: ./cmd/renfnv/main.go
binary: renfnv
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: rhash
main: ./cmd/rhash/main.go
binary: rhash
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: rolldie
main: ./cmd/rolldie/main.go
binary: rolldie
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: showimp
main: ./cmd/showimp/main.go
binary: showimp
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: ski
main: ./cmd/ski/main.go
binary: ski
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: sprox
main: ./cmd/sprox/main.go
binary: sprox
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: stealchain
main: ./cmd/stealchain/main.go
binary: stealchain
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: stealchain-server
main: ./cmd/stealchain-server/main.go
binary: stealchain-server
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: subjhash
main: ./cmd/subjhash/main.go
binary: subjhash
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: tlsinfo
main: ./cmd/tlsinfo/main.go
binary: tlsinfo
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: tlskeypair
main: ./cmd/tlskeypair/main.go
binary: tlskeypair
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: utc
main: ./cmd/utc/main.go
binary: utc
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: yamll
main: ./cmd/yamll/main.go
binary: yamll
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
- id: zsearch
main: ./cmd/zsearch/main.go
binary: zsearch
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ignore:
- goos: darwin
goarch: amd64
archives:
- formats: [tar.gz]
# archive filename: name_version_os_arch
name_template: >-
{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
formats: [zip]
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
release:
github:
owner: kisom
name: goutils
footer: >-
---
Released by [GoReleaser](https://github.com/goreleaser/goreleaser).

145
CHANGELOG
View File

@@ -1,5 +1,150 @@
CHANGELOG
v1.15.4 - 2025-11-19
Quality of life fixes for CSR generation.
v1.15.3 - 2025-11-19
Minor bug fixes.
v1.15.2 - 2025-11-19
Minor bug fixes.
v1.15.1 - 2025-11-19
Changed:
- linter fixes.
Removed:
- mnd removed from linter.
v1.15.0 - 2025-11-19
Changed:
- lib: fetcher and dialer moved to separate packages.
- cmd/ca-signed: cleaned up code internally.
- lib: add base64 encoding to HexEncode.
- linter fixes.
Added:
- certlib/certgen: add support for generating and signing certificates.
v1.14.6 - 2025-11-18
Added:
- certlib: move tlskeypair functions into certlib.
v1.14.5 - 2025-11-18
Changed:
- certlib/verify: fix a nil-pointer dereference.
v1.14.4 - 2025-11-18
Added:
- certlib/ski: add support for return certificate SKI.
- certlib/verify: add support for verifying certificates.
Changed:
- certlib/dump: moved more functions into the dump package.
- cmd: many certificate-related commands had their functionality moved into
certlib.
v1.14.3 - 2025-11-18
Added:
- certlib/dump: the certificate dumping functions have been moved into
their own package.
Changed:
- cmd/certdump: refactor out most of the functionality into certlib/dump.
- cmd/kgz: add extended metadata support.
v1.14.2 - 2025-11-18
Added:
- lib: add tooling for generating baseline TLS configs.
Changed:
- cmd: update all commands to allow the use strict TLS configs. Note that
many of these tools are intended for debugging broken or insecure TLS
systems, and the ability to support insecure TLS configurations is
important in this regard.
v1.14.1 - 2025-11-18
Added:
- build: add missing Dockerfile.
v1.14.0 - 2025-11-18
Added:
- lib/dialer: introduce proxy-aware dialers and helpers:
- NewNetDialer and NewTLSDialer honoring SOCKS5_PROXY, HTTPS_PROXY, HTTP_PROXY
(case-insensitive) with precedence SOCKS5 > HTTPS > HTTP.
- DialTCP and DialTLS convenience functions; DialTLS performs a TLS handshake
and returns a concrete *tls.Conn.
- NewHTTPClient: returns a proxy-aware *http.Client. Uses SOCKS5 proxy when
configured (disables HTTP(S) proxying to avoid double-proxying); otherwise
relies on http.ProxyFromEnvironment (respects HTTP(S)_PROXY and NO_PROXY).
- build: the releasse-docker.sh builds and pushes the correct Docker images.
Changed:
- cmd: migrate tools to new proxy-aware helpers where appropriate:
- certchain, stealchain, tlsinfo: use lib.DialTLS.
- cert-revcheck: use lib.DialTLS for site connects and a proxy-aware
HTTP client for OCSP/CRL fetches.
- rhash: use proxy-aware HTTP client for downloads.
- lib/fetch: migrate from certlib/fetch.go to lib/fetch.go and use DialTLS
under the hood.
- go.mod: add golang.org/x/net dependency (for SOCKS5 support) and align x/crypto.
Notes:
- HTTP(S) proxy CONNECT supports optional basic auth via proxy URL credentials.
- HTTPS proxies are TLS-wrapped prior to CONNECT.
- Timeouts apply to TCP connects, proxy handshakes, and TLS handshakes; context
cancellation is honored.
- Some commands retain bespoke dialing (e.g., IPv6-only or unix sockets) and
were intentionally left unchanged.
v1.13.6 - 2025-11-18
Changed:
- build: removing gitea stuff.
v1.13.5 - 2025-11-18
Changed:
- build: updating goreleaser config.
v1.13.4 - 2025-11-18
Changed:
- build: updating goreleaser config.
v1.13.3 - 2025-11-18
Added:
- certlib: introduce `Fetcher` for retrieving certificates.
- lib: `HexEncode` gains a byte-slice output variant.
- build: add GoReleaser configuration.
Changed:
- cmd: migrate programs to use `certlib.Fetcher` for certificate retrieval
(includes `certdump`, `ski`, and others).
- cmd/ski: update display mode.
Misc:
- repository fixups and small cleanups.
v1.13.2 - 2025-11-17
Add:
- certlib/bundler: refactor certificate bundling from cmd/cert-bundler
into a separate package.
Changed:
- cmd/cert-bundler: refactor to use bundler package, and update Dockerfile.
v1.13.1 - 2025-11-17
Add:

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# syntax=docker/dockerfile:1
# ----- Builder stage: build all cmd/... tools -----
FROM golang:1.24-alpine AS builder
# Install necessary build dependencies for fetching modules
RUN apk add --no-cache git ca-certificates && update-ca-certificates
WORKDIR /src
# Cache modules
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# Copy the rest of the source
COPY . .
# Build and install all commands under ./cmd/... into /out
ENV CGO_ENABLED=0
ENV GOBIN=/out
RUN --mount=type=cache,target=/go/pkg/mod \
go install ./cmd/...
# ----- Final runtime image: minimal alpine with tools installed -----
FROM alpine:3.20
# Ensure common utilities are present
RUN apk add --no-cache bash curl ca-certificates && update-ca-certificates
# Copy binaries from builder
COPY --from=builder /out/ /usr/local/bin/
# Working directory for mounting the host CWD
WORKDIR /work
# Default command shows available tools if run without args
CMD ["/bin/sh", "-lc", "echo 'Tools installed:' && ls -1 /usr/local/bin && echo '\nMount your project with: docker run --rm -it -v $PWD:/work IMAGE <tool> ...'"]

View File

@@ -91,7 +91,7 @@ func TestReset(t *testing.T) {
}
}
const decay = 25 * time.Millisecond
const decay = time.Second
const maxDuration = 10 * time.Millisecond
const interval = time.Millisecond

677
certlib/bundler/bundler.go Normal file
View File

@@ -0,0 +1,677 @@
package bundler
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gopkg.in/yaml.v2"
"git.wntrmute.dev/kyle/goutils/certlib"
)
const defaultFileMode = 0644
// Config represents the top-level YAML configuration.
type Config struct {
Config struct {
Hashes string `yaml:"hashes"`
Expiry string `yaml:"expiry"`
} `yaml:"config"`
Chains map[string]ChainGroup `yaml:"chains"`
}
// ChainGroup represents a named group of certificate chains.
type ChainGroup struct {
Certs []CertChain `yaml:"certs"`
Outputs Outputs `yaml:"outputs"`
}
// CertChain represents a root certificate and its intermediates.
type CertChain struct {
Root string `yaml:"root"`
Intermediates []string `yaml:"intermediates"`
}
// Outputs defines output format options.
type Outputs struct {
IncludeSingle bool `yaml:"include_single"`
IncludeIndividual bool `yaml:"include_individual"`
Manifest bool `yaml:"manifest"`
Formats []string `yaml:"formats"`
Encoding string `yaml:"encoding"`
}
var formatExtensions = map[string]string{
"zip": ".zip",
"tgz": ".tar.gz",
}
// Run performs the bundling operation given a config file path and an output directory.
func Run(configFile string, outputDir string) error {
if configFile == "" {
return errors.New("configuration file required")
}
cfg, err := loadConfig(configFile)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
expiryDuration := 365 * 24 * time.Hour
if cfg.Config.Expiry != "" {
expiryDuration, err = parseDuration(cfg.Config.Expiry)
if err != nil {
return fmt.Errorf("parsing expiry: %w", err)
}
}
if err = os.MkdirAll(outputDir, 0750); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
totalFormats := 0
for _, group := range cfg.Chains {
totalFormats += len(group.Outputs.Formats)
}
createdFiles := make([]string, 0, totalFormats)
for groupName, group := range cfg.Chains {
files, perr := processChainGroup(groupName, group, expiryDuration, outputDir)
if perr != nil {
return fmt.Errorf("processing chain group %s: %w", groupName, perr)
}
createdFiles = append(createdFiles, files...)
}
if cfg.Config.Hashes != "" {
hashFile := filepath.Join(outputDir, cfg.Config.Hashes)
if gerr := generateHashFile(hashFile, createdFiles); gerr != nil {
return fmt.Errorf("generating hash file: %w", gerr)
}
}
return nil
}
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if uerr := yaml.Unmarshal(data, &cfg); uerr != nil {
return nil, uerr
}
return &cfg, nil
}
func parseDuration(s string) (time.Duration, error) {
// Support simple formats like "1y", "6m", "30d"
if len(s) < 2 {
return 0, fmt.Errorf("invalid duration format: %s", s)
}
unit := s[len(s)-1]
value := s[:len(s)-1]
var multiplier time.Duration
switch unit {
case 'y', 'Y':
multiplier = 365 * 24 * time.Hour
case 'm', 'M':
multiplier = 30 * 24 * time.Hour
case 'd', 'D':
multiplier = 24 * time.Hour
default:
return time.ParseDuration(s)
}
var num int
_, err := fmt.Sscanf(value, "%d", &num)
if err != nil {
return 0, fmt.Errorf("invalid duration value: %s", s)
}
return time.Duration(num) * multiplier, nil
}
func processChainGroup(
groupName string,
group ChainGroup,
expiryDuration time.Duration,
outputDir string,
) ([]string, error) {
// Default encoding to "pem" if not specified
encoding := group.Outputs.Encoding
if encoding == "" {
encoding = "pem"
}
// Collect certificates from all chains in the group
singleFileCerts, individualCerts, sourcePaths, err := loadAndCollectCerts(
group.Certs,
group.Outputs,
expiryDuration,
)
if err != nil {
return nil, err
}
// Prepare files for inclusion in archives
archiveFiles, err := prepareArchiveFiles(singleFileCerts, individualCerts, sourcePaths, group.Outputs, encoding)
if err != nil {
return nil, err
}
// Create archives for the entire group
createdFiles, err := createArchiveFiles(groupName, group.Outputs.Formats, archiveFiles, outputDir)
if err != nil {
return nil, err
}
return createdFiles, nil
}
// loadAndCollectCerts loads all certificates from chains and collects them for processing.
func loadAndCollectCerts(
chains []CertChain,
outputs Outputs,
expiryDuration time.Duration,
) ([]*x509.Certificate, []certWithPath, []string, error) {
var singleFileCerts []*x509.Certificate
var individualCerts []certWithPath
var sourcePaths []string
for _, chain := range chains {
s, i, cerr := collectFromChain(chain, outputs, expiryDuration)
if cerr != nil {
return nil, nil, nil, cerr
}
if len(s) > 0 {
singleFileCerts = append(singleFileCerts, s...)
}
if len(i) > 0 {
individualCerts = append(individualCerts, i...)
}
// Record source paths for timestamp preservation
// Only append when loading succeeded
sourcePaths = append(sourcePaths, chain.Root)
sourcePaths = append(sourcePaths, chain.Intermediates...)
}
return singleFileCerts, individualCerts, sourcePaths, nil
}
// collectFromChain loads a single chain, performs checks, and returns the certs to include.
func collectFromChain(
chain CertChain,
outputs Outputs,
expiryDuration time.Duration,
) (
[]*x509.Certificate,
[]certWithPath,
error,
) {
var single []*x509.Certificate
var indiv []certWithPath
// Load root certificate
rootCert, rerr := certlib.LoadCertificate(chain.Root)
if rerr != nil {
return nil, nil, fmt.Errorf("failed to load root certificate %s: %w", chain.Root, rerr)
}
// Check expiry for root
checkExpiry(chain.Root, rootCert, expiryDuration)
// Add root to collections if needed
if outputs.IncludeSingle {
single = append(single, rootCert)
}
if outputs.IncludeIndividual {
indiv = append(indiv, certWithPath{cert: rootCert, path: chain.Root})
}
// Load and validate intermediates
for _, intPath := range chain.Intermediates {
intCert, lerr := certlib.LoadCertificate(intPath)
if lerr != nil {
return nil, nil, fmt.Errorf("failed to load intermediate certificate %s: %w", intPath, lerr)
}
// Validate that intermediate is signed by root
if sigErr := intCert.CheckSignatureFrom(rootCert); sigErr != nil {
return nil, nil, fmt.Errorf(
"intermediate %s is not properly signed by root %s: %w",
intPath,
chain.Root,
sigErr,
)
}
// Check expiry for intermediate
checkExpiry(intPath, intCert, expiryDuration)
// Add intermediate to collections if needed
if outputs.IncludeSingle {
single = append(single, intCert)
}
if outputs.IncludeIndividual {
indiv = append(indiv, certWithPath{cert: intCert, path: intPath})
}
}
return single, indiv, nil
}
// prepareArchiveFiles prepares all files to be included in archives.
func prepareArchiveFiles(
singleFileCerts []*x509.Certificate,
individualCerts []certWithPath,
sourcePaths []string,
outputs Outputs,
encoding string,
) ([]fileEntry, error) {
var archiveFiles []fileEntry
// Track used filenames to avoid collisions inside archives
usedNames := make(map[string]int)
// Handle a single bundle file
if outputs.IncludeSingle && len(singleFileCerts) > 0 {
bundleTime := maxModTime(sourcePaths)
files, err := encodeCertsToFiles(singleFileCerts, "bundle", encoding, true)
if err != nil {
return nil, fmt.Errorf("failed to encode single bundle: %w", err)
}
for i := range files {
files[i].name = makeUniqueName(files[i].name, usedNames)
files[i].modTime = bundleTime
// Best-effort: we do not have a portable birth/creation time.
// Use the same timestamp for created time to track deterministically.
files[i].createTime = bundleTime
}
archiveFiles = append(archiveFiles, files...)
}
// Handle individual files
if outputs.IncludeIndividual {
for _, cp := range individualCerts {
baseName := strings.TrimSuffix(filepath.Base(cp.path), filepath.Ext(cp.path))
files, err := encodeCertsToFiles([]*x509.Certificate{cp.cert}, baseName, encoding, false)
if err != nil {
return nil, fmt.Errorf("failed to encode individual cert %s: %w", cp.path, err)
}
mt := fileModTime(cp.path)
for i := range files {
files[i].name = makeUniqueName(files[i].name, usedNames)
files[i].modTime = mt
files[i].createTime = mt
}
archiveFiles = append(archiveFiles, files...)
}
}
// Generate manifest if requested
if outputs.Manifest {
manifestContent := generateManifest(archiveFiles)
manifestName := makeUniqueName("MANIFEST", usedNames)
mt := maxModTime(sourcePaths)
archiveFiles = append(archiveFiles, fileEntry{
name: manifestName,
content: manifestContent,
modTime: mt,
createTime: mt,
})
}
return archiveFiles, nil
}
// createArchiveFiles creates archive files in the specified formats.
func createArchiveFiles(
groupName string,
formats []string,
archiveFiles []fileEntry,
outputDir string,
) ([]string, error) {
createdFiles := make([]string, 0, len(formats))
for _, format := range formats {
ext, ok := formatExtensions[format]
if !ok {
return nil, fmt.Errorf("unsupported format: %s", format)
}
archivePath := filepath.Join(outputDir, groupName+ext)
switch format {
case "zip":
if err := createZipArchive(archivePath, archiveFiles); err != nil {
return nil, fmt.Errorf("failed to create zip archive: %w", err)
}
case "tgz":
if err := createTarGzArchive(archivePath, archiveFiles); err != nil {
return nil, fmt.Errorf("failed to create tar.gz archive: %w", err)
}
default:
return nil, fmt.Errorf("unsupported format: %s", format)
}
createdFiles = append(createdFiles, archivePath)
}
return createdFiles, nil
}
func checkExpiry(path string, cert *x509.Certificate, expiryDuration time.Duration) {
now := time.Now()
expiryThreshold := now.Add(expiryDuration)
if cert.NotAfter.Before(expiryThreshold) {
daysUntilExpiry := int(cert.NotAfter.Sub(now).Hours() / 24)
if daysUntilExpiry < 0 {
fmt.Fprintf(
os.Stderr,
"WARNING: Certificate %s has EXPIRED (expired %d days ago)\n",
path,
-daysUntilExpiry,
)
} else {
fmt.Fprintf(os.Stderr, "WARNING: Certificate %s will expire in %d days (on %s)\n", path, daysUntilExpiry, cert.NotAfter.Format("2006-01-02"))
}
}
}
type fileEntry struct {
name string
content []byte
modTime time.Time
createTime time.Time
}
type certWithPath struct {
cert *x509.Certificate
path string
}
// encodeCertsToFiles converts certificates to file entries based on encoding type
// If isSingle is true, certs are concatenated into a single file; otherwise one cert per file.
func encodeCertsToFiles(
certs []*x509.Certificate,
baseName string,
encoding string,
isSingle bool,
) ([]fileEntry, error) {
var files []fileEntry
switch encoding {
case "pem":
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
})
case "der":
if isSingle {
// For single file in DER, concatenate all cert DER bytes
var derContent []byte
for _, cert := range certs {
derContent = append(derContent, cert.Raw...)
}
files = append(files, fileEntry{
name: baseName + ".crt",
content: derContent,
})
} else if len(certs) > 0 {
// Individual DER file (should only have one cert)
files = append(files, fileEntry{
name: baseName + ".crt",
content: certs[0].Raw,
})
}
case "both":
// Add PEM version
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
})
// Add DER version
if isSingle {
var derContent []byte
for _, cert := range certs {
derContent = append(derContent, cert.Raw...)
}
files = append(files, fileEntry{
name: baseName + ".crt",
content: derContent,
})
} else if len(certs) > 0 {
files = append(files, fileEntry{
name: baseName + ".crt",
content: certs[0].Raw,
})
}
default:
return nil, fmt.Errorf("unsupported encoding: %s (must be 'pem', 'der', or 'both')", encoding)
}
return files, nil
}
// encodeCertsToPEM encodes certificates to PEM format.
func encodeCertsToPEM(certs []*x509.Certificate) []byte {
var pemContent []byte
for _, cert := range certs {
pemBlock := &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}
pemContent = append(pemContent, pem.EncodeToMemory(pemBlock)...)
}
return pemContent
}
func generateManifest(files []fileEntry) []byte {
// Build a sorted list of files by filename to ensure deterministic manifest ordering
sorted := make([]fileEntry, 0, len(files))
for _, f := range files {
// Defensive: skip any existing manifest entry
if f.name == "MANIFEST" {
continue
}
sorted = append(sorted, f)
}
sort.Slice(sorted, func(i, j int) bool { return sorted[i].name < sorted[j].name })
var manifest strings.Builder
for _, file := range sorted {
hash := sha256.Sum256(file.content)
manifest.WriteString(fmt.Sprintf("%x %s\n", hash, file.name))
}
return []byte(manifest.String())
}
// closeWithErr attempts to close all provided closers, joining any close errors with baseErr.
func closeWithErr(baseErr error, closers ...io.Closer) error {
for _, c := range closers {
if c == nil {
continue
}
if cerr := c.Close(); cerr != nil {
baseErr = errors.Join(baseErr, cerr)
}
}
return baseErr
}
func createZipArchive(path string, files []fileEntry) error {
f, zerr := os.Create(path)
if zerr != nil {
return zerr
}
w := zip.NewWriter(f)
for _, file := range files {
hdr := &zip.FileHeader{
Name: file.name,
Method: zip.Deflate,
}
if !file.modTime.IsZero() {
hdr.SetModTime(file.modTime)
}
fw, werr := w.CreateHeader(hdr)
if werr != nil {
return closeWithErr(werr, w, f)
}
if _, werr = fw.Write(file.content); werr != nil {
return closeWithErr(werr, w, f)
}
}
// Check errors on close operations
if cerr := w.Close(); cerr != nil {
_ = f.Close()
return cerr
}
return f.Close()
}
func createTarGzArchive(path string, files []fileEntry) error {
f, terr := os.Create(path)
if terr != nil {
return terr
}
gw := gzip.NewWriter(f)
tw := tar.NewWriter(gw)
for _, file := range files {
hdr := &tar.Header{
Name: file.name,
Uid: 0,
Gid: 0,
Mode: defaultFileMode,
Size: int64(len(file.content)),
ModTime: func() time.Time {
if file.modTime.IsZero() {
return time.Now()
}
return file.modTime
}(),
}
// Set additional times if supported
hdr.AccessTime = hdr.ModTime
if !file.createTime.IsZero() {
hdr.ChangeTime = file.createTime
} else {
hdr.ChangeTime = hdr.ModTime
}
if herr := tw.WriteHeader(hdr); herr != nil {
return closeWithErr(herr, tw, gw, f)
}
if _, werr := tw.Write(file.content); werr != nil {
return closeWithErr(werr, tw, gw, f)
}
}
// Check errors on close operations in the correct order
if cerr := tw.Close(); cerr != nil {
_ = gw.Close()
_ = f.Close()
return cerr
}
if cerr := gw.Close(); cerr != nil {
_ = f.Close()
return cerr
}
return f.Close()
}
func generateHashFile(path string, files []string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
for _, file := range files {
data, rerr := os.ReadFile(file)
if rerr != nil {
return rerr
}
hash := sha256.Sum256(data)
fmt.Fprintf(f, "%x %s\n", hash, filepath.Base(file))
}
return nil
}
// makeUniqueName ensures that each file name within the archive is unique by appending
// an incremental numeric suffix before the extension when collisions occur.
// Example: "root.pem" -> "root-2.pem", "root-3.pem", etc.
func makeUniqueName(name string, used map[string]int) string {
// If unused, mark and return as-is
if _, ok := used[name]; !ok {
used[name] = 1
return name
}
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
// Track a counter per base+ext key
key := base + ext
counter := max(used[key], 1)
for {
counter++
candidate := fmt.Sprintf("%s-%d%s", base, counter, ext)
if _, exists := used[candidate]; !exists {
used[key] = counter
used[candidate] = 1
return candidate
}
}
}
// fileModTime returns the file's modification time, or time.Now() if stat fails.
func fileModTime(path string) time.Time {
fi, err := os.Stat(path)
if err != nil {
return time.Now()
}
return fi.ModTime()
}
// maxModTime returns the latest modification time across provided paths.
// If the list is empty or stats fail, returns time.Now().
func maxModTime(paths []string) time.Time {
var zero time.Time
maxTime := zero
for _, p := range paths {
fi, err := os.Stat(p)
if err != nil {
continue
}
mt := fi.ModTime()
if maxTime.IsZero() || mt.After(maxTime) {
maxTime = mt
}
}
if maxTime.IsZero() {
return time.Now()
}
return maxTime
}

224
certlib/certgen/config.go Normal file
View File

@@ -0,0 +1,224 @@
package certgen
import (
"crypto"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"net"
"strings"
"time"
"git.wntrmute.dev/kyle/goutils/lib"
)
type KeySpec struct {
Algorithm string `yaml:"algorithm"`
Size int `yaml:"size"`
}
func (ks KeySpec) Generate() (crypto.PublicKey, crypto.PrivateKey, error) {
switch strings.ToLower(ks.Algorithm) {
case "rsa":
return GenerateKey(x509.RSA, ks.Size)
case "ecdsa":
return GenerateKey(x509.ECDSA, ks.Size)
case "ed25519":
return GenerateKey(x509.Ed25519, 0)
default:
return nil, nil, fmt.Errorf("unknown key algorithm: %s", ks.Algorithm)
}
}
func (ks KeySpec) SigningAlgorithm() (x509.SignatureAlgorithm, error) {
switch strings.ToLower(ks.Algorithm) {
case "rsa":
return x509.SHA512WithRSAPSS, nil
case "ecdsa":
return x509.ECDSAWithSHA512, nil
case "ed25519":
return x509.PureEd25519, nil
default:
return 0, fmt.Errorf("unknown key algorithm: %s", ks.Algorithm)
}
}
type Subject struct {
CommonName string `yaml:"common_name"`
Country string `yaml:"country"`
Locality string `yaml:"locality"`
Province string `yaml:"province"`
Organization string `yaml:"organization"`
OrganizationalUnit string `yaml:"organizational_unit"`
Email string `yaml:"email"`
DNSNames []string `yaml:"dns"`
IPAddresses []string `yaml:"ips"`
}
type CertificateRequest struct {
KeySpec KeySpec `yaml:"key"`
Subject Subject `yaml:"subject"`
Profile Profile `yaml:"profile"`
}
func (cs CertificateRequest) Request(priv crypto.PrivateKey) (*x509.CertificateRequest, error) {
subject := pkix.Name{}
subject.CommonName = cs.Subject.CommonName
subject.Country = []string{cs.Subject.Country}
subject.Locality = []string{cs.Subject.Locality}
subject.Province = []string{cs.Subject.Province}
subject.Organization = []string{cs.Subject.Organization}
subject.OrganizationalUnit = []string{cs.Subject.OrganizationalUnit}
ipAddresses := make([]net.IP, 0, len(cs.Subject.IPAddresses))
for i, ip := range cs.Subject.IPAddresses {
ipAddresses = append(ipAddresses, net.ParseIP(ip))
if ipAddresses[i] == nil {
return nil, fmt.Errorf("invalid IP address: %s", ip)
}
}
req := &x509.CertificateRequest{
PublicKeyAlgorithm: 0,
PublicKey: getPublic(priv),
Subject: subject,
DNSNames: cs.Subject.DNSNames,
IPAddresses: ipAddresses,
}
reqBytes, err := x509.CreateCertificateRequest(rand.Reader, req, priv)
if err != nil {
return nil, fmt.Errorf("failed to create certificate request: %w", err)
}
req, err = x509.ParseCertificateRequest(reqBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate request: %w", err)
}
return req, nil
}
func (cs CertificateRequest) Generate() (crypto.PrivateKey, *x509.CertificateRequest, error) {
_, priv, err := cs.KeySpec.Generate()
if err != nil {
return nil, nil, err
}
req, err := cs.Request(priv)
if err != nil {
return nil, nil, err
}
return priv, req, nil
}
type Profile struct {
IsCA bool `yaml:"is_ca"`
PathLen int `yaml:"path_len"`
KeyUse string `yaml:"key_uses"`
ExtKeyUsages []string `yaml:"ext_key_usages"`
Expiry string `yaml:"expiry"`
}
func (p Profile) templateFromRequest(req *x509.CertificateRequest) (*x509.Certificate, error) {
serial, err := SerialNumber()
if err != nil {
return nil, fmt.Errorf("failed to generate serial number: %w", err)
}
expiry, err := lib.ParseDuration(p.Expiry)
if err != nil {
return nil, fmt.Errorf("parsing expiry: %w", err)
}
certTemplate := &x509.Certificate{
SignatureAlgorithm: req.SignatureAlgorithm,
PublicKeyAlgorithm: req.PublicKeyAlgorithm,
PublicKey: req.PublicKey,
SerialNumber: serial,
Subject: req.Subject,
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(expiry),
BasicConstraintsValid: true,
IsCA: p.IsCA,
MaxPathLen: p.PathLen,
DNSNames: req.DNSNames,
IPAddresses: req.IPAddresses,
}
var ok bool
certTemplate.KeyUsage, ok = keyUsageStrings[p.KeyUse]
if !ok {
return nil, fmt.Errorf("invalid key usage: %s", p.KeyUse)
}
var eku x509.ExtKeyUsage
for _, extKeyUsage := range p.ExtKeyUsages {
eku, ok = extKeyUsageStrings[extKeyUsage]
if !ok {
return nil, fmt.Errorf("invalid extended key usage: %s", extKeyUsage)
}
certTemplate.ExtKeyUsage = append(certTemplate.ExtKeyUsage, eku)
}
return certTemplate, nil
}
func (p Profile) SignRequest(
parent *x509.Certificate,
req *x509.CertificateRequest,
priv crypto.PrivateKey,
) (*x509.Certificate, error) {
tpl, err := p.templateFromRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to create certificate template: %w", err)
}
certBytes, err := x509.CreateCertificate(rand.Reader, tpl, parent, req.PublicKey, priv)
if err != nil {
return nil, fmt.Errorf("failed to create certificate: %w", err)
}
cert, err := x509.ParseCertificate(certBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
return cert, nil
}
func (p Profile) SelfSign(req *x509.CertificateRequest, priv crypto.PrivateKey) (*x509.Certificate, error) {
certTemplate, err := p.templateFromRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to create certificate template: %w", err)
}
return p.SignRequest(certTemplate, req, priv)
}
func SerialNumber() (*big.Int, error) {
serialNumberBytes := make([]byte, 20)
_, err := rand.Read(serialNumberBytes)
if err != nil {
return nil, fmt.Errorf("failed to generate serial number: %w", err)
}
return new(big.Int).SetBytes(serialNumberBytes), nil
}
// GenerateSelfSigned generates a self-signed certificate using the given certificate request.
func GenerateSelfSigned(creq *CertificateRequest) (*x509.Certificate, crypto.PrivateKey, error) {
priv, req, err := creq.Generate()
if err != nil {
return nil, nil, fmt.Errorf("failed to generate certificate request: %w", err)
}
cert, err := creq.Profile.SelfSign(req, priv)
if err != nil {
return nil, nil, fmt.Errorf("failed to self-sign certificate: %w", err)
}
return cert, priv, nil
}

86
certlib/certgen/keygen.go Normal file
View File

@@ -0,0 +1,86 @@
package certgen
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"errors"
"fmt"
)
// var (
// oidEd25519 = asn1.ObjectIdentifier{1, 3, 101, 110}
//)
func GenerateKey(algorithm x509.PublicKeyAlgorithm, bitSize int) (crypto.PublicKey, crypto.PrivateKey, error) {
var key crypto.PrivateKey
var pub crypto.PublicKey
var err error
switch algorithm {
case x509.RSA:
pub, key, err = ed25519.GenerateKey(rand.Reader)
case x509.Ed25519:
key, err = rsa.GenerateKey(rand.Reader, bitSize)
if err == nil {
rsaPriv, ok := key.(*rsa.PrivateKey)
if !ok {
panic("failed to cast RSA private key to *rsa.PrivateKey")
}
pub = rsaPriv.Public()
}
case x509.ECDSA:
var curve elliptic.Curve
switch bitSize {
case 256:
curve = elliptic.P256()
case 384:
curve = elliptic.P384()
case 521:
curve = elliptic.P521()
default:
return nil, nil, fmt.Errorf("unsupported curve size %d", bitSize)
}
key, err = ecdsa.GenerateKey(curve, rand.Reader)
if err == nil {
ecPriv, ok := key.(*ecdsa.PrivateKey)
if !ok {
panic("failed to cast ECDSA private key to *ecdsa.PrivateKey")
}
pub = ecPriv.Public()
}
case x509.DSA:
fallthrough
case x509.UnknownPublicKeyAlgorithm:
fallthrough
default:
err = errors.New("unsupported algorithm")
}
if err != nil {
return nil, nil, err
}
return pub, key, nil
}
func getPublic(priv crypto.PrivateKey) crypto.PublicKey {
switch priv := priv.(type) {
case *rsa.PrivateKey:
return &priv.PublicKey
case *ecdsa.PrivateKey:
return &priv.PublicKey
case *ed25519.PrivateKey:
return priv.Public()
default:
return nil
}
}

32
certlib/certgen/ku.go Normal file
View File

@@ -0,0 +1,32 @@
package certgen
import "crypto/x509"
var keyUsageStrings = map[string]x509.KeyUsage{
"signing": x509.KeyUsageDigitalSignature,
"digital signature": x509.KeyUsageDigitalSignature,
"content commitment": x509.KeyUsageContentCommitment,
"key encipherment": x509.KeyUsageKeyEncipherment,
"key agreement": x509.KeyUsageKeyAgreement,
"data encipherment": x509.KeyUsageDataEncipherment,
"cert sign": x509.KeyUsageCertSign,
"crl sign": x509.KeyUsageCRLSign,
"encipher only": x509.KeyUsageEncipherOnly,
"decipher only": x509.KeyUsageDecipherOnly,
}
var extKeyUsageStrings = map[string]x509.ExtKeyUsage{
"any": x509.ExtKeyUsageAny,
"server auth": x509.ExtKeyUsageServerAuth,
"client auth": x509.ExtKeyUsageClientAuth,
"code signing": x509.ExtKeyUsageCodeSigning,
"email protection": x509.ExtKeyUsageEmailProtection,
"s/mime": x509.ExtKeyUsageEmailProtection,
"ipsec end system": x509.ExtKeyUsageIPSECEndSystem,
"ipsec tunnel": x509.ExtKeyUsageIPSECTunnel,
"ipsec user": x509.ExtKeyUsageIPSECUser,
"timestamping": x509.ExtKeyUsageTimeStamping,
"ocsp signing": x509.ExtKeyUsageOCSPSigning,
"microsoft sgc": x509.ExtKeyUsageMicrosoftServerGatedCrypto,
"netscape sgc": x509.ExtKeyUsageNetscapeServerGatedCrypto,
}

View File

@@ -1,9 +1,12 @@
package certlib
import (
"bytes"
"crypto"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"git.wntrmute.dev/kyle/goutils/certlib/certerr"
@@ -12,6 +15,7 @@ import (
// ReadCertificate reads a DER or PEM-encoded certificate from the
// byte slice.
func ReadCertificate(in []byte) (*x509.Certificate, []byte, error) {
in = bytes.TrimSpace(in)
if len(in) == 0 {
return nil, nil, certerr.ParsingError(certerr.ErrorSourceCertificate, certerr.ErrEmptyCertificate)
}
@@ -23,10 +27,10 @@ func ReadCertificate(in []byte) (*x509.Certificate, []byte, error) {
}
rest := remaining
if p.Type != "CERTIFICATE" {
if p.Type != pemTypeCertificate {
return nil, rest, certerr.ParsingError(
certerr.ErrorSourceCertificate,
certerr.ErrInvalidPEMType(p.Type, "CERTIFICATE"),
certerr.ErrInvalidPEMType(p.Type, pemTypeCertificate),
)
}
@@ -93,3 +97,41 @@ func LoadCertificates(path string) ([]*x509.Certificate, error) {
return ReadCertificates(in)
}
func PoolFromBytes(certBytes []byte) (*x509.CertPool, error) {
pool := x509.NewCertPool()
certs, err := ReadCertificates(certBytes)
if err != nil {
return nil, fmt.Errorf("failed to read certificates: %w", err)
}
for _, cert := range certs {
pool.AddCert(cert)
}
return pool, nil
}
func ExportPrivateKeyPEM(priv crypto.PrivateKey) ([]byte, error) {
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: pemTypePrivateKey, Bytes: keyDER}), nil
}
func LoadCSR(path string) (*x509.CertificateRequest, error) {
in, err := os.ReadFile(path)
if err != nil {
return nil, certerr.LoadingError(certerr.ErrorSourceCSR, err)
}
req, _, err := ParseCSR(in)
return req, err
}
func ExportCSRAsPEM(req *x509.CertificateRequest) []byte {
return pem.EncodeToMemory(&pem.Block{Type: pemTypeCertificateRequest, Bytes: req.Raw})
}

335
certlib/dump/dump.go Normal file
View File

@@ -0,0 +1,335 @@
// Package dump implements tooling for dumping certificate information.
package dump
import (
"crypto/dsa"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/kr/text"
"git.wntrmute.dev/kyle/goutils/lib"
)
const (
sSHA256 = "SHA256"
sSHA512 = "SHA512"
)
var keyUsage = map[x509.KeyUsage]string{
x509.KeyUsageDigitalSignature: "digital signature",
x509.KeyUsageContentCommitment: "content commitment",
x509.KeyUsageKeyEncipherment: "key encipherment",
x509.KeyUsageKeyAgreement: "key agreement",
x509.KeyUsageDataEncipherment: "data encipherment",
x509.KeyUsageCertSign: "cert sign",
x509.KeyUsageCRLSign: "crl sign",
x509.KeyUsageEncipherOnly: "encipher only",
x509.KeyUsageDecipherOnly: "decipher only",
}
var extKeyUsages = map[x509.ExtKeyUsage]string{
x509.ExtKeyUsageAny: "any",
x509.ExtKeyUsageServerAuth: "server auth",
x509.ExtKeyUsageClientAuth: "client auth",
x509.ExtKeyUsageCodeSigning: "code signing",
x509.ExtKeyUsageEmailProtection: "s/mime",
x509.ExtKeyUsageIPSECEndSystem: "ipsec end system",
x509.ExtKeyUsageIPSECTunnel: "ipsec tunnel",
x509.ExtKeyUsageIPSECUser: "ipsec user",
x509.ExtKeyUsageTimeStamping: "timestamping",
x509.ExtKeyUsageOCSPSigning: "ocsp signing",
x509.ExtKeyUsageMicrosoftServerGatedCrypto: "microsoft sgc",
x509.ExtKeyUsageNetscapeServerGatedCrypto: "netscape sgc",
x509.ExtKeyUsageMicrosoftCommercialCodeSigning: "microsoft commercial code signing",
x509.ExtKeyUsageMicrosoftKernelCodeSigning: "microsoft kernel code signing",
}
func sigAlgoPK(a x509.SignatureAlgorithm) string {
switch a {
case x509.MD2WithRSA, x509.MD5WithRSA, x509.SHA1WithRSA, x509.SHA256WithRSA, x509.SHA384WithRSA, x509.SHA512WithRSA:
return "RSA"
case x509.SHA256WithRSAPSS, x509.SHA384WithRSAPSS, x509.SHA512WithRSAPSS:
return "RSA-PSS"
case x509.ECDSAWithSHA1, x509.ECDSAWithSHA256, x509.ECDSAWithSHA384, x509.ECDSAWithSHA512:
return "ECDSA"
case x509.DSAWithSHA1, x509.DSAWithSHA256:
return "DSA"
case x509.PureEd25519:
return "Ed25519"
case x509.UnknownSignatureAlgorithm:
return "unknown public key algorithm"
default:
return "unknown public key algorithm"
}
}
func sigAlgoHash(a x509.SignatureAlgorithm) string {
switch a {
case x509.MD2WithRSA:
return "MD2"
case x509.MD5WithRSA:
return "MD5"
case x509.SHA1WithRSA, x509.ECDSAWithSHA1, x509.DSAWithSHA1:
return "SHA1"
case x509.SHA256WithRSA, x509.ECDSAWithSHA256, x509.DSAWithSHA256:
return sSHA256
case x509.SHA256WithRSAPSS:
return sSHA256
case x509.SHA384WithRSA, x509.ECDSAWithSHA384:
return "SHA384"
case x509.SHA384WithRSAPSS:
return "SHA384"
case x509.SHA512WithRSA, x509.ECDSAWithSHA512:
return sSHA512
case x509.SHA512WithRSAPSS:
return sSHA512
case x509.PureEd25519:
return sSHA512
case x509.UnknownSignatureAlgorithm:
return "unknown hash algorithm"
default:
return "unknown hash algorithm"
}
}
const maxLine = 78
func makeIndent(n int) string {
s := " "
var sSb97 strings.Builder
for range n {
sSb97.WriteString(" ")
}
s += sSb97.String()
return s
}
func indentLen(n int) int {
return 4 + (8 * n)
}
// this isn't real efficient, but that's not a problem here.
func wrap(s string, indent int) string {
if indent > 3 {
indent = 3
}
wrapped := text.Wrap(s, maxLine)
lines := strings.SplitN(wrapped, "\n", 2)
if len(lines) == 1 {
return lines[0]
}
if (maxLine - indentLen(indent)) <= 0 {
panic("too much indentation")
}
rest := strings.Join(lines[1:], " ")
wrapped = text.Wrap(rest, maxLine-indentLen(indent))
return lines[0] + "\n" + text.Indent(wrapped, makeIndent(indent))
}
func dumpHex(in []byte) string {
return lib.HexEncode(in, lib.HexEncodeUpperColon)
}
func certPublic(cert *x509.Certificate) string {
switch pub := cert.PublicKey.(type) {
case *rsa.PublicKey:
return fmt.Sprintf("RSA-%d", pub.N.BitLen())
case *ecdsa.PublicKey:
switch pub.Curve {
case elliptic.P256():
return "ECDSA-prime256v1"
case elliptic.P384():
return "ECDSA-secp384r1"
case elliptic.P521():
return "ECDSA-secp521r1"
default:
return "ECDSA (unknown curve)"
}
case *dsa.PublicKey:
return "DSA"
default:
return "Unknown"
}
}
func DisplayName(name pkix.Name) string {
var ns []string
if name.CommonName != "" {
ns = append(ns, name.CommonName)
}
for i := range name.Country {
ns = append(ns, fmt.Sprintf("C=%s", name.Country[i]))
}
for i := range name.Organization {
ns = append(ns, fmt.Sprintf("O=%s", name.Organization[i]))
}
for i := range name.OrganizationalUnit {
ns = append(ns, fmt.Sprintf("OU=%s", name.OrganizationalUnit[i]))
}
for i := range name.Locality {
ns = append(ns, fmt.Sprintf("L=%s", name.Locality[i]))
}
for i := range name.Province {
ns = append(ns, fmt.Sprintf("ST=%s", name.Province[i]))
}
if len(ns) > 0 {
return "/" + strings.Join(ns, "/")
}
return "*** no subject information ***"
}
func keyUsages(ku x509.KeyUsage) string {
var uses []string
for u, s := range keyUsage {
if (ku & u) != 0 {
uses = append(uses, s)
}
}
sort.Strings(uses)
return strings.Join(uses, ", ")
}
func extUsage(ext []x509.ExtKeyUsage) string {
ns := make([]string, 0, len(ext))
for i := range ext {
ns = append(ns, extKeyUsages[ext[i]])
}
sort.Strings(ns)
return strings.Join(ns, ", ")
}
func showBasicConstraints(cert *x509.Certificate) {
fmt.Fprint(os.Stdout, "\tBasic constraints: ")
if cert.BasicConstraintsValid {
fmt.Fprint(os.Stdout, "valid")
} else {
fmt.Fprint(os.Stdout, "invalid")
}
if cert.IsCA {
fmt.Fprint(os.Stdout, ", is a CA certificate")
if !cert.BasicConstraintsValid {
fmt.Fprint(os.Stdout, " (basic constraint failure)")
}
} else {
fmt.Fprint(os.Stdout, ", is not a CA certificate")
if cert.KeyUsage&x509.KeyUsageKeyEncipherment != 0 {
fmt.Fprint(os.Stdout, " (key encipherment usage enabled!)")
}
}
if (cert.MaxPathLen == 0 && cert.MaxPathLenZero) || (cert.MaxPathLen > 0) {
fmt.Fprintf(os.Stdout, ", max path length %d", cert.MaxPathLen)
}
fmt.Fprintln(os.Stdout)
}
func wrapPrint(text string, indent int) {
tabs := ""
var tabsSb140 strings.Builder
for range indent {
tabsSb140.WriteString("\t")
}
tabs += tabsSb140.String()
fmt.Fprintf(os.Stdout, tabs+"%s\n", wrap(text, indent))
}
func DisplayCert(w io.Writer, cert *x509.Certificate, showHash bool) {
fmt.Fprintln(w, "CERTIFICATE")
if showHash {
fmt.Fprintln(w, wrap(fmt.Sprintf("SHA256: %x", sha256.Sum256(cert.Raw)), 0))
}
fmt.Fprintln(w, wrap("Subject: "+DisplayName(cert.Subject), 0))
fmt.Fprintln(w, wrap("Issuer: "+DisplayName(cert.Issuer), 0))
fmt.Fprintf(w, "\tSignature algorithm: %s / %s\n", sigAlgoPK(cert.SignatureAlgorithm),
sigAlgoHash(cert.SignatureAlgorithm))
fmt.Fprintln(w, "Details:")
wrapPrint("Public key: "+certPublic(cert), 1)
fmt.Fprintf(w, "\tSerial number: %s\n", cert.SerialNumber)
if len(cert.AuthorityKeyId) > 0 {
fmt.Fprintf(w, "\t%s\n", wrap("AKI: "+dumpHex(cert.AuthorityKeyId), 1))
}
if len(cert.SubjectKeyId) > 0 {
fmt.Fprintf(w, "\t%s\n", wrap("SKI: "+dumpHex(cert.SubjectKeyId), 1))
}
wrapPrint("Valid from: "+cert.NotBefore.Format(lib.DateShortFormat), 1)
fmt.Fprintf(w, "\t until: %s\n", cert.NotAfter.Format(lib.DateShortFormat))
fmt.Fprintf(w, "\tKey usages: %s\n", keyUsages(cert.KeyUsage))
if len(cert.ExtKeyUsage) > 0 {
fmt.Fprintf(w, "\tExtended usages: %s\n", extUsage(cert.ExtKeyUsage))
}
showBasicConstraints(cert)
validNames := make([]string, 0, len(cert.DNSNames)+len(cert.EmailAddresses)+len(cert.IPAddresses))
for i := range cert.DNSNames {
validNames = append(validNames, "dns:"+cert.DNSNames[i])
}
for i := range cert.EmailAddresses {
validNames = append(validNames, "email:"+cert.EmailAddresses[i])
}
for i := range cert.IPAddresses {
validNames = append(validNames, "ip:"+cert.IPAddresses[i].String())
}
sans := fmt.Sprintf("SANs (%d): %s\n", len(validNames), strings.Join(validNames, ", "))
wrapPrint(sans, 1)
l := len(cert.IssuingCertificateURL)
if l != 0 {
var aia string
if l == 1 {
aia = "AIA"
} else {
aia = "AIAs"
}
wrapPrint(fmt.Sprintf("%d %s:", l, aia), 1)
for _, url := range cert.IssuingCertificateURL {
wrapPrint(url, 2)
}
}
l = len(cert.OCSPServer)
if l > 0 {
title := "OCSP server"
if l > 1 {
title += "s"
}
wrapPrint(title+":\n", 1)
for _, ocspServer := range cert.OCSPServer {
wrapPrint(fmt.Sprintf("- %s\n", ocspServer), 2)
}
}
}

View File

@@ -75,6 +75,12 @@ var DelegationExtension = pkix.Extension{
Value: []byte{0x05, 0x00}, // ASN.1 NULL
}
const (
pemTypeCertificate = "CERTIFICATE"
pemTypeCertificateRequest = "CERTIFICATE REQUEST"
pemTypePrivateKey = "PRIVATE KEY"
)
// InclusiveDate returns the time.Time representation of a date - 1
// nanosecond. This allows time.After to be used inclusively.
func InclusiveDate(year int, month time.Month, day int) time.Time {
@@ -246,7 +252,7 @@ func EncodeCertificatesPEM(certs []*x509.Certificate) []byte {
var buffer bytes.Buffer
for _, cert := range certs {
if err := pem.Encode(&buffer, &pem.Block{
Type: "CERTIFICATE",
Type: pemTypeCertificate,
Bytes: cert.Raw,
}); err != nil {
return nil
@@ -396,6 +402,45 @@ func ParseOneCertificateFromPEM(certsPEM []byte) ([]*x509.Certificate, []byte, e
return certs, rest, nil
}
// LoadFullCertPool returns a certificate pool with roots and intermediates
// from disk. If no roots are provided, the system root pool will be used.
func LoadFullCertPool(roots, intermediates string) (*x509.CertPool, error) {
var err error
pool := x509.NewCertPool()
if roots == "" {
pool, err = x509.SystemCertPool()
if err != nil {
return nil, fmt.Errorf("loading system cert pool: %w", err)
}
} else {
var rootCerts []*x509.Certificate
rootCerts, err = LoadCertificates(roots)
if err != nil {
return nil, fmt.Errorf("loading roots: %w", err)
}
for _, cert := range rootCerts {
pool.AddCert(cert)
}
}
if intermediates != "" {
var intCerts []*x509.Certificate
intCerts, err = LoadCertificates(intermediates)
if err != nil {
return nil, fmt.Errorf("loading intermediates: %w", err)
}
for _, cert := range intCerts {
pool.AddCert(cert)
}
}
return pool, nil
}
// LoadPEMCertPool loads a pool of PEM certificates from file.
func LoadPEMCertPool(certsFile string) (*x509.CertPool, error) {
if certsFile == "" {

View File

@@ -1,3 +1,13 @@
// Package hosts provides a simple way to parse hostnames and ports.
// Supported formats are:
// - https://example.com:8080
// - https://example.com
// - tls://example.com:8080
// - tls://example.com
// - example.com:8080
// - example.com
//
// Hosts parsed here are expected to be TLS hosts, and the port defaults to 443.
package hosts
import (
@@ -26,7 +36,12 @@ func parseURL(host string) (string, int, error) {
return "", 0, fmt.Errorf("certlib/hosts: invalid host: %s", host)
}
if strings.ToLower(url.Scheme) != "https" {
switch strings.ToLower(url.Scheme) {
case "https":
// OK
case "tls":
// OK
default:
return "", 0, errors.New("certlib/hosts: only https scheme supported")
}
@@ -43,28 +58,28 @@ func parseURL(host string) (string, int, error) {
}
func parseHostPort(host string) (string, int, error) {
host, sport, err := net.SplitHostPort(host)
shost, sport, err := net.SplitHostPort(host)
if err == nil {
portInt, err2 := strconv.ParseInt(sport, 10, 16)
if err2 != nil {
return "", 0, fmt.Errorf("certlib/hosts: invalid port: %s", sport)
}
return host, int(portInt), nil
return shost, int(portInt), nil
}
return host, defaultHTTPSPort, nil
}
func ParseHost(host string) (*Target, error) {
host, port, err := parseURL(host)
uhost, port, err := parseURL(host)
if err == nil {
return &Target{Host: host, Port: port}, nil
return &Target{Host: uhost, Port: port}, nil
}
host, port, err = parseHostPort(host)
shost, port, err := parseHostPort(host)
if err == nil {
return &Target{Host: host, Port: port}, nil
return &Target{Host: shost, Port: port}, nil
}
return nil, fmt.Errorf("certlib/hosts: invalid host: %s", host)

View File

@@ -0,0 +1,35 @@
package hosts_test
import (
"testing"
"git.wntrmute.dev/kyle/goutils/certlib/hosts"
)
type testCase struct {
Host string
Target hosts.Target
}
var testCases = []testCase{
{Host: "server-name", Target: hosts.Target{Host: "server-name", Port: 443}},
{Host: "server-name:8443", Target: hosts.Target{Host: "server-name", Port: 8443}},
{Host: "tls://server-name", Target: hosts.Target{Host: "server-name", Port: 443}},
{Host: "https://server-name", Target: hosts.Target{Host: "server-name", Port: 443}},
{Host: "https://server-name:8443", Target: hosts.Target{Host: "server-name", Port: 8443}},
{Host: "tls://server-name:8443", Target: hosts.Target{Host: "server-name", Port: 8443}},
{Host: "https://server-name/something/else", Target: hosts.Target{Host: "server-name", Port: 443}},
}
func TestParseHost(t *testing.T) {
for i, tc := range testCases {
target, err := hosts.ParseHost(tc.Host)
if err != nil {
t.Fatalf("test case %d: %s", i+1, err)
}
if target.Host != tc.Target.Host {
t.Fatalf("test case %d: got host '%s', want host '%s'", i+1, target.Host, tc.Target.Host)
}
}
}

135
certlib/keymatch.go Normal file
View File

@@ -0,0 +1,135 @@
package certlib
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
)
// LoadPrivateKey loads a private key from disk. It accepts both PEM and DER
// encodings and supports RSA and ECDSA keys. If the file contains a PEM block,
// the block type must be one of the recognised private key types.
func LoadPrivateKey(path string) (crypto.Signer, error) {
in, err := os.ReadFile(path)
if err != nil {
return nil, err
}
in = bytes.TrimSpace(in)
if p, _ := pem.Decode(in); p != nil {
if !validPEMs[p.Type] {
return nil, errors.New("invalid private key file type " + p.Type)
}
return ParsePrivateKeyPEM(in)
}
return ParsePrivateKeyDER(in)
}
var validPEMs = map[string]bool{
"PRIVATE KEY": true,
"RSA PRIVATE KEY": true,
"EC PRIVATE KEY": true,
}
const (
curveInvalid = iota // any invalid curve
curveRSA // indicates key is an RSA key, not an EC key
curveP256
curveP384
curveP521
)
func getECCurve(pub any) int {
switch pub := pub.(type) {
case *ecdsa.PublicKey:
switch pub.Curve {
case elliptic.P256():
return curveP256
case elliptic.P384():
return curveP384
case elliptic.P521():
return curveP521
default:
return curveInvalid
}
case *rsa.PublicKey:
return curveRSA
default:
return curveInvalid
}
}
// matchRSA compares an RSA public key from certificate against RSA public key from private key.
// It returns true on match.
func matchRSA(certPub *rsa.PublicKey, keyPub *rsa.PublicKey) bool {
return keyPub.N.Cmp(certPub.N) == 0 && keyPub.E == certPub.E
}
// matchECDSA compares ECDSA public keys for equality and compatible curve.
// It returns match=true when they are on the same curve and have the same X/Y.
// If curves mismatch, match is false.
func matchECDSA(certPub *ecdsa.PublicKey, keyPub *ecdsa.PublicKey) bool {
if getECCurve(certPub) != getECCurve(keyPub) {
return false
}
if keyPub.X.Cmp(certPub.X) != 0 {
return false
}
if keyPub.Y.Cmp(certPub.Y) != 0 {
return false
}
return true
}
// MatchKeys determines whether the certificate's public key matches the given private key.
// It returns true if they match; otherwise, it returns false and a human-friendly reason.
func MatchKeys(cert *x509.Certificate, priv crypto.Signer) (bool, string) {
switch keyPub := priv.Public().(type) {
case *rsa.PublicKey:
switch certPub := cert.PublicKey.(type) {
case *rsa.PublicKey:
if matchRSA(certPub, keyPub) {
return true, ""
}
return false, "public keys don't match"
case *ecdsa.PublicKey:
return false, "RSA private key, EC public key"
default:
return false, fmt.Sprintf("unsupported certificate public key type: %T", cert.PublicKey)
}
case *ecdsa.PublicKey:
switch certPub := cert.PublicKey.(type) {
case *ecdsa.PublicKey:
if matchECDSA(certPub, keyPub) {
return true, ""
}
// Determine a more precise reason
kc := getECCurve(keyPub)
cc := getECCurve(certPub)
if kc == curveInvalid {
return false, "invalid private key curve"
}
if cc == curveRSA {
return false, "private key is EC, certificate is RSA"
}
if kc != cc {
return false, "EC curves don't match"
}
return false, "public keys don't match"
case *rsa.PublicKey:
return false, "private key is EC, certificate is RSA"
default:
return false, fmt.Sprintf("unsupported certificate public key type: %T", cert.PublicKey)
}
default:
return false, fmt.Sprintf("unrecognised private key type: %T", priv.Public())
}
}

49
certlib/keymatch_test.go Normal file
View File

@@ -0,0 +1,49 @@
package certlib_test
import (
"testing"
"git.wntrmute.dev/kyle/goutils/certlib"
)
var (
testCert1 = "testdata/cert1.pem"
testCert2 = "testdata/cert2.pem"
testPriv1 = "testdata/priv1.pem"
testPriv2 = "testdata/priv2.pem"
)
type testCase struct {
cert string
key string
match bool
}
var testCases = []testCase{
{testCert1, testPriv1, true},
{testCert2, testPriv2, true},
{testCert1, testPriv2, false},
{testCert2, testPriv1, false},
}
func TestMatchKeys(t *testing.T) {
for i, tc := range testCases {
cert, err := certlib.LoadCertificate(tc.cert)
if err != nil {
t.Fatalf("failed to load cert %d: %v", i, err)
}
priv, err := certlib.LoadPrivateKey(tc.key)
if err != nil {
t.Fatalf("failed to load key %d: %v", i, err)
}
ok, _ := certlib.MatchKeys(cert, priv)
switch {
case ok && !tc.match:
t.Fatalf("case %d: cert %s/key %s should not match", i, tc.cert, tc.key)
case !ok && tc.match:
t.Fatalf("case %d: cert %s/key %s should match", i, tc.cert, tc.key)
}
}
}

157
certlib/ski/ski.go Normal file
View File

@@ -0,0 +1,157 @@
package ski
import (
"bytes"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/sha1" // #nosec G505 this is the standard
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
"fmt"
"os"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
)
const (
keyTypeRSA = "RSA"
keyTypeECDSA = "ECDSA"
keyTypeEd25519 = "Ed25519"
)
type subjectPublicKeyInfo struct {
Algorithm pkix.AlgorithmIdentifier
SubjectPublicKey asn1.BitString
}
type KeyInfo struct {
PublicKey []byte
KeyType string
FileType string
}
func (k *KeyInfo) String() string {
return fmt.Sprintf("%s (%s)", lib.HexEncode(k.PublicKey, lib.HexEncodeLowerColon), k.KeyType)
}
func (k *KeyInfo) SKI(displayMode lib.HexEncodeMode) (string, error) {
var subPKI subjectPublicKeyInfo
_, err := asn1.Unmarshal(k.PublicKey, &subPKI)
if err != nil {
return "", fmt.Errorf("serializing SKI: %w", err)
}
pubHash := sha1.Sum(subPKI.SubjectPublicKey.Bytes) // #nosec G401 this is the standard
pubHashString := lib.HexEncode(pubHash[:], displayMode)
return pubHashString, nil
}
// ParsePEM parses a PEM file and returns the public key and its type.
func ParsePEM(path string) (*KeyInfo, error) {
material := &KeyInfo{}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("parsing X.509 material %s: %w", path, err)
}
data = bytes.TrimSpace(data)
p, rest := pem.Decode(data)
if len(rest) > 0 {
lib.Warnx("trailing data in PEM file")
}
if p == nil {
return nil, fmt.Errorf("no PEM data in %s", path)
}
data = p.Bytes
switch p.Type {
case "PRIVATE KEY", "RSA PRIVATE KEY", "EC PRIVATE KEY":
material.PublicKey, material.KeyType = parseKey(data)
material.FileType = "private key"
case "CERTIFICATE":
material.PublicKey, material.KeyType = parseCertificate(data)
material.FileType = "certificate"
case "CERTIFICATE REQUEST":
material.PublicKey, material.KeyType = parseCSR(data)
material.FileType = "certificate request"
default:
return nil, fmt.Errorf("unknown PEM type %s", p.Type)
}
return material, nil
}
func parseKey(data []byte) ([]byte, string) {
priv, err := certlib.ParsePrivateKeyDER(data)
if err != nil {
die.If(err)
}
var kt string
switch priv.Public().(type) {
case *rsa.PublicKey:
kt = keyTypeRSA
case *ecdsa.PublicKey:
kt = keyTypeECDSA
default:
die.With("unknown private key type %T", priv)
}
public, err := x509.MarshalPKIXPublicKey(priv.Public())
die.If(err)
return public, kt
}
func parseCertificate(data []byte) ([]byte, string) {
cert, err := x509.ParseCertificate(data)
die.If(err)
pub := cert.PublicKey
var kt string
switch pub.(type) {
case *rsa.PublicKey:
kt = keyTypeRSA
case *ecdsa.PublicKey:
kt = keyTypeECDSA
case *ed25519.PublicKey:
kt = keyTypeEd25519
default:
die.With("unknown public key type %T", pub)
}
public, err := x509.MarshalPKIXPublicKey(pub)
die.If(err)
return public, kt
}
func parseCSR(data []byte) ([]byte, string) {
// Use certlib to support both PEM and DER and to centralize validation.
csr, _, err := certlib.ParseCSR(data)
die.If(err)
pub := csr.PublicKey
var kt string
switch pub.(type) {
case *rsa.PublicKey:
kt = keyTypeRSA
case *ecdsa.PublicKey:
kt = keyTypeECDSA
default:
die.With("unknown public key type %T", pub)
}
public, err := x509.MarshalPKIXPublicKey(pub)
die.If(err)
return public, kt
}

23
certlib/testdata/cert1.pem vendored Normal file
View File

@@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIID2zCCAsOgAwIBAgIUN0qOIUWB0UCmtutt2RH6PCmcuhEwDQYJKoZIhvcNAQEL
BQAwfTELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExIjAgBgNVBAoM
GVdOVFJNVVRFIEhlYXZ5IEluZHVzdHJpZXMxHzAdBgNVBAsMFkNyeXB0b2dyYXBo
aWMgU2VydmljZXMxFDASBgNVBAMMC3Rlc3QtY2VydC0xMB4XDTI1MTExOTA4MjM1
MFoXDTQ1MTExNDA4MjM1MFowfTELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlm
b3JuaWExIjAgBgNVBAoMGVdOVFJNVVRFIEhlYXZ5IEluZHVzdHJpZXMxHzAdBgNV
BAsMFkNyeXB0b2dyYXBoaWMgU2VydmljZXMxFDASBgNVBAMMC3Rlc3QtY2VydC0x
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7sbIJyBfBBF2oHnFOfLS
rtcIUpZcz0fJ9JNtjzazwfyykVV9nuIC4JyD+VhxxSnSQN1H6kHqmcNNJlsQkGjK
TcA6wcFxMRcWyaV5MY3U7MTe1WJJXTrpAFYTOoo0pQaoONBaWn48qfdQc9OvtU17
wgBFhNWfdJaDKDAcyz4pHj9ihl80brvThOwrhUAWRw3ooyZ3m+T8Bgrkqp4ZPv3w
A8oaAoA91UKT5yKRcIAJHAkE4ep0UZdcNPKhBu7L5Jqh8I4EtG0FnZKkOR7gpw+y
YhIhuewWlQWRJwXBv3TwX9njmKwfE6Uftgy9HPbc66mK61FR3fEsU9KHaCmkXDwH
SQIDAQABo1MwUTAdBgNVHQ4EFgQUD2idNc+Yq+6am5/+lizTVJ5HRBUwHwYDVR0j
BBgwFoAUD2idNc+Yq+6am5/+lizTVJ5HRBUwDwYDVR0TAQH/BAUwAwEB/zANBgkq
hkiG9w0BAQsFAAOCAQEAcsa8Htaxw4HhtS8mboC41+FiqFisXfASO0LbsCLGjmrg
Vi9MP9cg06g1AjxxlYw9KsbSXdn/jdbVqcQJxGItZ+CE1AcwUVg3c4ZmPOGIl4LS
Pv2p2Lv4nCRWXrbp96O+lmC1xclziUTYGdQO9pNi71LcSapjLNlxWCWyvAJhWrVe
zZHjGi1nG6ygpPXpldXFyyw61xpjPKc1eghoI125Am5xr3YhPjLM9IGGA1i6R9rC
TlKjQOy8nUPC00jZrAf+HWdMWSpa320eOPi+qz18qbyfl8KMOBFvmA3mdumoABGn
Mre0Gq9fUcd/KdPEHu++XAcLH3M8pqmeUQHHHse0gQ==
-----END CERTIFICATE-----

34
certlib/testdata/cert2.pem vendored Normal file
View File

@@ -0,0 +1,34 @@
-----BEGIN CERTIFICATE-----
MIIF2zCCA8OgAwIBAgIUXosHyc+4br2XvK+fLJ+6uG8G/eYwDQYJKoZIhvcNAQEL
BQAwfTELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExIjAgBgNVBAoM
GVdOVFJNVVRFIEhlYXZ5IEluZHVzdHJpZXMxHzAdBgNVBAsMFkNyeXB0b2dyYXBo
aWMgU2VydmljZXMxFDASBgNVBAMMC3Rlc3QtY2VydC0yMB4XDTI1MTExOTA4MjQy
MloXDTQ1MTExNDA4MjQyMlowfTELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlm
b3JuaWExIjAgBgNVBAoMGVdOVFJNVVRFIEhlYXZ5IEluZHVzdHJpZXMxHzAdBgNV
BAsMFkNyeXB0b2dyYXBoaWMgU2VydmljZXMxFDASBgNVBAMMC3Rlc3QtY2VydC0y
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA8G39r3JD0RNTT2d1Omtf
WSxv2XzSgSmiAZl6wpcmvE/C9smltXskK/74vxTRpTSoTVtMi1dNWbZlYag+BaqF
60Cp0kGPESIyLDtUQCZpQypKYjOXVPiwd9xXGAdE7Br7dFaUArGRJiWzPX/vjgdK
mruRk+c3ABFhdbiq3CWCPz3uheu9ekUTgK8CEAFsWg2ehTjWIEJU61M6AITvSIUZ
GUEaNC3cAeP7Wx3Vy694fT9WoHpyr6dtWsTzbWyuSPtQ8uR2BEcunUxiBtQthio5
xv20ZgD9C+dJnwr9tE7JKh1MCrFQNkt7EedKABTVYxYxMVATYUUg+jZPy68v1KnL
kYIeB/TBB6iVGIOc9EKWjGv+luebR7OGgu3sZTFxsW5Dq0LSjzLJqoKROtYEEnJt
sWo6V1j7WMs1MPl8NtqqmJjlSJx/OUaVuseB/uji107aIMEKgOwTmFDfPdVYDhQG
eQ3V0Ro25/A/oe5yxEDNnSWGPtOHRq7aSJHM3/0qaPxg+RPrObb3ISRkXs5GBOHV
ss+Nk4McbCV6Zccy6gi+wrz7fiXHijpSWcVVfN9A61TSTTjAX+S9CphcjkS4I2JW
OJY5i9ANP62mr73d2NSikoTXavUCgOBlW00m0gAR0JJYNe/31yS96UwvH0xCHxer
1tX3qwGEjE0fhnwMhxP4tmkCAwEAAaNTMFEwHQYDVR0OBBYEFCs+ZbZ0uorYlg26
m5PSz4Ov66KEMB8GA1UdIwQYMBaAFCs+ZbZ0uorYlg26m5PSz4Ov66KEMA8GA1Ud
EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBAJneix0lCM4CqdrajmaHa8Y4
Sdr3URSufzW0l8zoBWss1z88X9aZemWKCd4UDSVpN+T6hEASvAw6zRSd2WkmCsdq
KnwHFnDDWGANt/CBcbr69Sk09YLMO+F0Cku8Ymp2jcAFy074E/wjwxgT6JJ/BtQE
q1JJNusanYN2jrYamB1PUnc4lWyOIOOTIU6oqofcJobtTJbSAA7Gvx4p85TMBnQu
YJdBQ3jnFFH3pjCXA9BXaZnaiJjnfDggsJJT7CXngC4US/ti4qZr7+Poc0Ikb/Pm
8EChKKvljZEtcxrhLhsVEzsJtk72F9Ravl+q2jS1zDqnS3OY6kf45nuYnvZ4QkX4
Nk8Y6PmGGk00QCAxyVsiFrm7wZHHvnQyQr8nxjPOv2MryV5e3rW9WAzAG4vHPS1F
5wi3ELiuivkoO5daDwzfVsKhQ3Nl2uAfS8pvY/NvTVPJnR+wdduJqgLMzAWhbRnx
r6WxuiY9mdkdkr6PDDnrw/4lm+GRFw8ksn8ErB3nZf73lo1Ai+Iv2FIi5Ore/qeq
vZjVNvBpZBiMo5d2zDWtp3m8vWgmgXDaKZXn0YAJATkqnhAKbdZ2cmwbGTZhIdrZ
pqoq2KPY+luirIIDiKDbkW4b2HRxwSM8cI2HxONGcB43FcZHlpMhOtM3DD0Z8lQD
b2Hi9ZK8kpL8qa2vFpOe
-----END CERTIFICATE-----

28
certlib/testdata/priv1.pem vendored Normal file
View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDuxsgnIF8EEXag
ecU58tKu1whSllzPR8n0k22PNrPB/LKRVX2e4gLgnIP5WHHFKdJA3UfqQeqZw00m
WxCQaMpNwDrBwXExFxbJpXkxjdTsxN7VYkldOukAVhM6ijSlBqg40Fpafjyp91Bz
06+1TXvCAEWE1Z90loMoMBzLPikeP2KGXzRuu9OE7CuFQBZHDeijJneb5PwGCuSq
nhk+/fADyhoCgD3VQpPnIpFwgAkcCQTh6nRRl1w08qEG7svkmqHwjgS0bQWdkqQ5
HuCnD7JiEiG57BaVBZEnBcG/dPBf2eOYrB8TpR+2DL0c9tzrqYrrUVHd8SxT0odo
KaRcPAdJAgMBAAECggEALeHOK7CNeYFmj2MeyioWIGkrDP2eM2lqzf+3VYXwKEZH
xOQN2cY5wdHpjTQY1odZAsRSkZnde/L6o/RrPCiauTKHR9yFRObYJuLQZTyJDf8t
h4jVqp/Ljpg7pSvR/mUHVbV5qzpnK0zd7Yffk2Hidk6pjSMkexmB9eq62bYl3gz2
dlgKrLgjlwUmhD0P5OhwCW2Z2rmrGwY1y3pj/FjvIckxpPcEle0o/xUIEbW7lZux
3fCAu2Lvg+I9qE5MaWIfZX4aUQi5gJmUZpUCuDJjwFIztO+vSqKmw4zOUFKCRrAc
VsicvHvwmhUCrVT/ebEkf0ntSQq1ED0FARJdYhfOlQKBgQD8ngiviLbVPxVur6Wo
tMzNUUpaJxfyWfZ4w5eYLWKkYSlax1HMCLYyMU0dwSWdmmri+ibm91+VXEJ5DxQh
O/nIF5f0DpWcFmnl4C16xlouWiY6kaSTALQfy/PnsEsEd7oljxesqrpdw7s7/S8q
OUGkTP20M+U0WQQ/RNDWZoyMbQKBgQDx+U1I28ceHSrE6ss/ufWBt2WqiyqvC2NN
444/WkBps5XWUN0HSOBrr8PlMY4jsxyPXuqDVn6P4yg26zIRrIvBLonZ1v1PAMbk
nL1kVB78QOxS/xYOOO2Y2YFtPSztmFZnm8b7l/+9YzHAVp4IrpTsny6UyVZaYVSD
3v7XowlkzQKBgGJrO50P2ZOZQUNfYV4qGoR/gEVBZ93+2LzSDzS1sfGy/QamEyM3
3awOcyn9fyc46x3FMfTYOcAaMrexfTk5gaZIMuZd7EHkpZtuzKlBsA7RBoXZClJP
et3MexkwIPn7n2VUq3eVCIjRYhgMGx0LM5zMdieH9GuBptrzd52gVG+9AoGAVhRL
7AlTMmFJ37dvCoKK1dR6NEtBqfexIfo7lkny9CdQvGcT2g2Q2H40gAo6+HQ1SsOH
RaW1bFZw7eiJbUQmi1iU7YvPnRU3rAgeT9ylETO/Xl8kZ3bU/zURF91VaEhzJHSE
Ouh9r8/j2Pp3SbthezO9jGx7bbeGK0te+TMkmlkCgYAwYst1HRndKGMVdNPCEdlW
aye+R3VtpTWGqDCJiMQCMUsCZF8KcYkCAQk7nXh55putfvTwnWfnqRn91e9yp+/7
rsE3vnGRcbkjvcgZaFyZL7800pOYWEm8FF2xRSBBC49b8kjZPA1i5OME2P0Y4lon
naIddZmTj87qOtEaY/MSGQ==
-----END PRIVATE KEY-----

52
certlib/testdata/priv2.pem vendored Normal file
View File

@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDwbf2vckPRE1NP
Z3U6a19ZLG/ZfNKBKaIBmXrClya8T8L2yaW1eyQr/vi/FNGlNKhNW0yLV01ZtmVh
qD4FqoXrQKnSQY8RIjIsO1RAJmlDKkpiM5dU+LB33FcYB0TsGvt0VpQCsZEmJbM9
f++OB0qau5GT5zcAEWF1uKrcJYI/Pe6F6716RROArwIQAWxaDZ6FONYgQlTrUzoA
hO9IhRkZQRo0LdwB4/tbHdXLr3h9P1agenKvp21axPNtbK5I+1Dy5HYERy6dTGIG
1C2GKjnG/bRmAP0L50mfCv20TskqHUwKsVA2S3sR50oAFNVjFjExUBNhRSD6Nk/L
ry/UqcuRgh4H9MEHqJUYg5z0QpaMa/6W55tHs4aC7exlMXGxbkOrQtKPMsmqgpE6
1gQScm2xajpXWPtYyzUw+Xw22qqYmOVInH85RpW6x4H+6OLXTtogwQqA7BOYUN89
1VgOFAZ5DdXRGjbn8D+h7nLEQM2dJYY+04dGrtpIkczf/Spo/GD5E+s5tvchJGRe
zkYE4dWyz42TgxxsJXplxzLqCL7CvPt+JceKOlJZxVV830DrVNJNOMBf5L0KmFyO
RLgjYlY4ljmL0A0/raavvd3Y1KKShNdq9QKA4GVbTSbSABHQklg17/fXJL3pTC8f
TEIfF6vW1ferAYSMTR+GfAyHE/i2aQIDAQABAoICAEMrJ1VNgd62HG8xgxGYD6I1
BOZotdJ51BXIUABvA9ZWHiyd9xp1VYypBcs0QMF7rY029XJ0KFro1vfqbbFdi15G
yWrA//wUZpnu1UG6uWuXNAKtURjfBUXnG7nNxhaEDz3YNi9udhOHMsT6qe0u4kvK
HQiJ7tapBGZD+g/YtsN+RNXLHzs6cxFfUx8vlpqt9VxYnZGTlm/L54dfnA3RiUqB
4pUzPqSUkZNKCYGG+w1alZPtwX6LMsTKAwvN8f7XnyzMYKAfVsmBHl20ByfVQiDy
neRlYExkCDBTfL9Tx2Vpm+Xc1YDlo3ND/2t4ZojxGTsimNdy3Zypca+AuMcbzI/G
fTY/qSQHrP/bz07oYwvFXUQhcVzuA6/DPzVL017SSOxCHTM2l4MItV4NE1ZLlEmq
ehzzqgSMgtyse8axWuYdzCfo7coHJESSJHxdxwbNDyQNZnVeQ0/hQ6w6GsQpKfKT
QjpxYuZlysLxwFtIB/5Qg9nUjZbWtA08shrSH5vY2YKjdV+84no4ilhfrsm3sNb+
msrm3NcsNy3lhMDvp15yx9f89j4mpyaxzp6CZa6jhW4BVAUxxRSL0MBaX+06JEsF
g8WVoZkCGyq3W8CaCzHG0gD0CYf1tKRXrwwDzVEUVN2N/u2BRJRogP5zPyjHi8Fl
hOu+0f5mlu89n1rse9CVAoIBAQD7Vg7sPaS4et3K/NqIAe7HWElGB0aN/p8Coiwv
xcamZAKYO3IbT/bgo0tJ2DynJJicBrfq4LMd97rldRjiD3CH88JFBEgm/L7HvYnQ
wZh/OiLuUyrGKbAgUjbUVDnFDgFrxN1sdSG43l9N5+hJ4Yz47Jek5/8pZ1uOR70N
usvPSKgcpcW8BJ1MwoOCQhaXhN+Yc/Y5FkPZ35C+IiRXJ/Hl6J0TCX/L48IKTdY9
9F5wh9gHHxU+y0FFNbsD3PuwzYJsdxlVg0mbHLnHy8rKt9tJ/TDM+dXGsrOzimKG
uZIIShyhQg1B/C7vOU3e5o2SNd7isaI6JqKWNwF7OLE6jpQrAoIBAQD05B9jYMjt
NS3423V4Y7Qw0hMXfz/r36VLNUw3tBLbybL/qmBkEt4tYdn7FihVB6u4PRxWDh28
A3XkQiIp+Awwn5CzixBf5cdSiozN673LzwMWOqHvfEjav5gWGabeCO2R62Zdt9Jt
VcGwrHU+9F0gBySOB+OAp5HTTf9Y8ItQgcNeYDZUQzgArRJRBMrIZjx3jAYMb6N6
SVQRYDZ0VDBvLpTGbJ7wDoQkYZ80jou6eBov6O3WXGkEVHJec9ULNWOvUgPU+SOB
NJ2vLJuKmQxacPjtyo87BePYQoUpmYA389BdQkK+wFy8t/m4cpGb/h0uWAonFCA1
fAGQFKnEAvG7AoIBAE41LDWUxPHmwadNYQ7bUxrSvRI+Z1T9+yrNneRLrZHPIwON
0+btzgt+pInY8J6uA5LhgE9lFjdoA88szc5iMYkMb9IcD/uZwB/VOdIsu7AzPfVd
Cb1Z8YVNL+SIROWtgwGu45vBIvossAlE9YIv3jcDH/jffAW9NL8kUY65JnxcxnsL
lmj4Ip5lFJjuyariXNVKmD6RUBG2wIp5g0dflaUN6fqnhQ3D1HhyWg0zQkPP8Yfd
wzWj9656lrQQCn2spT3tHYP/c2MB4Elsf7Du3xy53Xqa70uCBesDT79OdUOBFEGV
lRyIRW6JLVMD+N+bRbzSu4FOzl7hxOM78+IdxbsCggEBAOD9UUUpX5BngmQXpHZG
C/+qkbXNyDl6EM/nGK44t/bL+bNgogxvNUa2luFTexyb3o13P7hkYbch6scaZ27t
oK1vfC8oPZQNdLIF7tUlmAtOlsRue+ad5gVrb1wmlyN5SmL8xeCmiSLAXiJmX5XG
RmSti00ePEswKQ7coxPgc+40Of1UIbYKx8H/QEvFPlUdcMJYmBoG20f3ZNBN99mq
m5EaV79xfhiJDairM+zCZeecflq0Awclgapjt2vFud8BXyNtE24wswj7AUA2mHSe
pjXVgy5dIniUsb83ZkZQ6/b7/twfi1jbPJh54mkugU6zCbZRVoqOuATLeFgaU9ps
5g8CggEBAIqE5wD2ezkZaN5XHBbYvMqzrnA2TQLxy+KD3XjuPE/EWidPz7nF0Z3q
ucYBSyeak0dM6ZKcJFRVYcd3zr9Ssee5YO7n6ZE0AJdBJJSY3WAULTAO7GEQiIVS
e2ptLBkaJv5Wsl558gTVVXzgTXyoprwTVeeOact8VnmIea3mHVghPAG6oPzgG2v8
PDE64Zdu/OZs5nvEd262u5svsb0dgCPkXtKofkfxRhV7yDRtIkVe7qyK1Gq8BxNA
wi5i7S0WTO1qmyu3l93JzSGYyca8US34KB7DIYO5u2yQRNhBkbKDRpkvPNIycY+J
UAkHH7gJHv8bZgg5FVXt0B9875Z9qAI=
-----END PRIVATE KEY-----

49
certlib/verify/check.go Normal file
View File

@@ -0,0 +1,49 @@
package verify
import (
"crypto/x509"
"fmt"
"time"
"git.wntrmute.dev/kyle/goutils/certlib/dump"
)
const DefaultLeeway = 2160 * time.Hour // three months
type CertCheck struct {
Cert *x509.Certificate
leeway time.Duration
}
func NewCertCheck(cert *x509.Certificate, leeway time.Duration) *CertCheck {
return &CertCheck{
Cert: cert,
leeway: leeway,
}
}
func (c CertCheck) Expiry() time.Duration {
return time.Until(c.Cert.NotAfter)
}
func (c CertCheck) IsExpiring(leeway time.Duration) bool {
return c.Expiry() < leeway
}
// Err returns nil if the certificate is not expiring within the leeway period.
func (c CertCheck) Err() error {
if !c.IsExpiring(c.leeway) {
return nil
}
return fmt.Errorf("%s expires in %s", dump.DisplayName(c.Cert.Subject), c.Expiry())
}
func (c CertCheck) Name() string {
return fmt.Sprintf("%s/SN=%s", dump.DisplayName(c.Cert.Subject),
c.Cert.SerialNumber)
}
func (c CertCheck) String() string {
return fmt.Sprintf("%s expires on %s (in %s)\n", c.Name(), c.Cert.NotAfter, c.Expiry())
}

144
certlib/verify/verify.go Normal file
View File

@@ -0,0 +1,144 @@
package verify
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"git.wntrmute.dev/kyle/goutils/certlib/revoke"
"git.wntrmute.dev/kyle/goutils/lib/dialer"
"git.wntrmute.dev/kyle/goutils/lib/fetch"
)
func bundleIntermediates(w io.Writer, chain []*x509.Certificate, pool *x509.CertPool, verbose bool) *x509.CertPool {
for _, intermediate := range chain[1:] {
if verbose {
fmt.Fprintf(w, "[+] adding intermediate with SKI %x\n", intermediate.SubjectKeyId)
}
pool.AddCert(intermediate)
}
return pool
}
type Opts struct {
Verbose bool
Config *tls.Config
Intermediates *x509.CertPool
ForceIntermediates bool
CheckRevocation bool
KeyUsages []x509.ExtKeyUsage
}
type verifyResult struct {
chain []*x509.Certificate
roots *x509.CertPool
ints *x509.CertPool
}
func prepareVerification(w io.Writer, target string, opts *Opts) (*verifyResult, error) {
var (
roots, ints *x509.CertPool
err error
)
if opts == nil {
opts = &Opts{
Config: dialer.StrictBaselineTLSConfig(),
ForceIntermediates: false,
}
}
if opts.Config.RootCAs == nil {
roots, err = x509.SystemCertPool()
if err != nil {
return nil, fmt.Errorf("couldn't load system cert pool: %w", err)
}
opts.Config.RootCAs = roots
}
if opts.Intermediates == nil {
ints = x509.NewCertPool()
} else {
ints = opts.Intermediates.Clone()
}
roots = opts.Config.RootCAs.Clone()
chain, err := fetch.GetCertificateChain(target, opts.Config)
if err != nil {
return nil, fmt.Errorf("fetching certificate chain: %w", err)
}
if opts.Verbose {
fmt.Fprintf(w, "[+] %s has %d certificates\n", target, len(chain))
}
if len(chain) > 1 && opts.ForceIntermediates {
ints = bundleIntermediates(w, chain, ints, opts.Verbose)
}
return &verifyResult{
chain: chain,
roots: roots,
ints: ints,
}, nil
}
// Chain fetches the certificate chain for a target and verifies it.
func Chain(w io.Writer, target string, opts *Opts) ([]*x509.Certificate, error) {
result, err := prepareVerification(w, target, opts)
if err != nil {
return nil, fmt.Errorf("certificate verification failed: %w", err)
}
chains, err := CertWith(result.chain[0], result.roots, result.ints, opts.CheckRevocation, opts.KeyUsages...)
if err != nil {
return nil, fmt.Errorf("certificate verification failed: %w", err)
}
return chains, nil
}
// CertWith verifies a certificate against a set of roots and intermediates.
func CertWith(
cert *x509.Certificate,
roots, ints *x509.CertPool,
checkRevocation bool,
keyUses ...x509.ExtKeyUsage,
) ([]*x509.Certificate, error) {
if len(keyUses) == 0 {
keyUses = []x509.ExtKeyUsage{x509.ExtKeyUsageAny}
}
opts := x509.VerifyOptions{
Intermediates: ints,
Roots: roots,
KeyUsages: keyUses,
}
chains, err := cert.Verify(opts)
if err != nil {
return nil, err
}
if checkRevocation {
revoked, ok := revoke.VerifyCertificate(cert)
if !ok {
return nil, errors.New("failed to check certificate revocation status")
}
if revoked {
return nil, errors.New("certificate is revoked")
}
}
if len(chains) == 0 {
return nil, errors.New("no valid certificate chain found")
}
return chains[0], nil
}

View File

@@ -1,157 +1,24 @@
package main
import (
"bytes"
"crypto/x509"
"embed"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"time"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/certlib/verify"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
"git.wntrmute.dev/kyle/goutils/lib/dialer"
"git.wntrmute.dev/kyle/goutils/lib/fetch"
)
// loadCertsFromFile attempts to parse certificates from a file that may be in
// PEM or DER/PKCS#7 format. Returns the parsed certificates or an error.
func loadCertsFromFile(path string) ([]*x509.Certificate, error) {
var certs []*x509.Certificate
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if certs, err = certlib.ParseCertificatesPEM(data); err == nil {
return certs, nil
}
if certs, _, err = certlib.ParseCertificatesDER(data, ""); err == nil {
return certs, nil
}
return nil, err
}
func makePoolFromFile(path string) (*x509.CertPool, error) {
// Try PEM via helper (it builds a pool)
if pool, err := certlib.LoadPEMCertPool(path); err == nil && pool != nil {
return pool, nil
}
// Fallback: read as DER(s), add to a new pool
certs, err := loadCertsFromFile(path)
if err != nil || len(certs) == 0 {
return nil, fmt.Errorf("failed to load CA certificates from %s", path)
}
pool := x509.NewCertPool()
for _, c := range certs {
pool.AddCert(c)
}
return pool, nil
}
//go:embed testdata/*.pem
var embeddedTestdata embed.FS
// loadCertsFromBytes attempts to parse certificates from bytes that may be in
// PEM or DER/PKCS#7 format.
func loadCertsFromBytes(data []byte) ([]*x509.Certificate, error) {
certs, err := certlib.ParseCertificatesPEM(data)
if err == nil {
return certs, nil
}
certs, _, err = certlib.ParseCertificatesDER(data, "")
if err == nil {
return certs, nil
}
return nil, err
}
func makePoolFromBytes(data []byte) (*x509.CertPool, error) {
certs, err := loadCertsFromBytes(data)
if err != nil || len(certs) == 0 {
return nil, errors.New("failed to load CA certificates from embedded bytes")
}
pool := x509.NewCertPool()
for _, c := range certs {
pool.AddCert(c)
}
return pool, nil
}
// isSelfSigned returns true if the given certificate is self-signed.
// It checks that the subject and issuer match and that the certificate's
// signature verifies against its own public key.
func isSelfSigned(cert *x509.Certificate) bool {
if cert == nil {
return false
}
// Quick check: subject and issuer match
if cert.Subject.String() != cert.Issuer.String() {
return false
}
// Cryptographic check: the certificate is signed by itself
if err := cert.CheckSignatureFrom(cert); err != nil {
return false
}
return true
}
func verifyAgainstCA(caPool *x509.CertPool, path string) (bool, string) {
certs, err := loadCertsFromFile(path)
if err != nil || len(certs) == 0 {
return false, ""
}
leaf := certs[0]
ints := x509.NewCertPool()
if len(certs) > 1 {
for _, ic := range certs[1:] {
ints.AddCert(ic)
}
}
opts := x509.VerifyOptions{
Roots: caPool,
Intermediates: ints,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}
if _, err = leaf.Verify(opts); err != nil {
return false, ""
}
return true, leaf.NotAfter.Format("2006-01-02")
}
func verifyAgainstCABytes(caPool *x509.CertPool, certData []byte) (bool, string) {
certs, err := loadCertsFromBytes(certData)
if err != nil || len(certs) == 0 {
return false, ""
}
leaf := certs[0]
ints := x509.NewCertPool()
if len(certs) > 1 {
for _, ic := range certs[1:] {
ints.AddCert(ic)
}
}
opts := x509.VerifyOptions{
Roots: caPool,
Intermediates: ints,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}
if _, err = leaf.Verify(opts); err != nil {
return false, ""
}
return true, leaf.NotAfter.Format("2006-01-02")
}
type testCase struct {
name string
caFile string
@@ -170,18 +37,25 @@ func (tc testCase) Run() error {
return fmt.Errorf("selftest: failed to read embedded %s: %w", tc.certFile, err)
}
pool, err := makePoolFromBytes(caBytes)
pool, err := certlib.PoolFromBytes(caBytes)
if err != nil || pool == nil {
return fmt.Errorf("selftest: failed to build CA pool for %s: %w", tc.caFile, err)
}
ok, exp := verifyAgainstCABytes(pool, certBytes)
cert, _, err := certlib.ReadCertificate(certBytes)
if err != nil {
return fmt.Errorf("selftest: failed to parse certificate from %s: %w", tc.certFile, err)
}
_, err = verify.CertWith(cert, pool, nil, false)
ok := err == nil
if ok != tc.expectOK {
return fmt.Errorf("%s: unexpected result: got %v, want %v", tc.name, ok, tc.expectOK)
}
if ok {
fmt.Printf("%s: OK (expires %s)\n", tc.name, exp)
fmt.Printf("%s: OK (expires %s)\n", tc.name, cert.NotAfter.Format(lib.DateShortFormat))
}
fmt.Printf("%s: INVALID (as expected)\n", tc.name)
@@ -237,14 +111,16 @@ func selftest() int {
failures++
continue
}
certs, err := loadCertsFromBytes(b)
certs, err := certlib.ReadCertificates(b)
if err != nil || len(certs) == 0 {
fmt.Fprintf(os.Stderr, "selftest: failed to parse cert(s) from %s: %v\n", root, err)
failures++
continue
}
leaf := certs[0]
if isSelfSigned(leaf) {
if len(leaf.AuthorityKeyId) == 0 || bytes.Equal(leaf.AuthorityKeyId, leaf.SubjectKeyId) {
fmt.Printf("%s: SELF-SIGNED (as expected)\n", root)
} else {
fmt.Printf("%s: expected SELF-SIGNED, but was not detected as such\n", root)
@@ -260,66 +136,58 @@ func selftest() int {
return 1
}
// expiryString returns a YYYY-MM-DD date string to display for certificate
// expiry. If an explicit exp string is provided, it is used. Otherwise, if a
// leaf certificate is available, its NotAfter is formatted. As a last resort,
// it falls back to today's date (should not normally happen).
func expiryString(leaf *x509.Certificate, exp string) string {
if exp != "" {
return exp
}
if leaf != nil {
return leaf.NotAfter.Format("2006-01-02")
}
return time.Now().Format("2006-01-02")
}
// processCert verifies a single certificate file against the provided CA pool
// and prints the result in the required format, handling self-signed
// certificates specially.
func processCert(caPool *x509.CertPool, certPath string) {
ok, exp := verifyAgainstCA(caPool, certPath)
name := filepath.Base(certPath)
// Try to load the leaf cert for self-signed detection and expiry fallback
var leaf *x509.Certificate
if certs, err := loadCertsFromFile(certPath); err == nil && len(certs) > 0 {
leaf = certs[0]
}
// Prefer the SELF-SIGNED label if applicable
if isSelfSigned(leaf) {
fmt.Printf("%s: SELF-SIGNED\n", name)
return
}
if ok {
fmt.Printf("%s: OK (expires %s)\n", name, expiryString(leaf, exp))
return
}
fmt.Printf("%s: INVALID\n", name)
}
func main() {
// Special selftest mode: single argument "selftest"
if len(os.Args) == 2 && os.Args[1] == "selftest" {
var skipVerify, useStrict bool
dialer.StrictTLSFlag(&useStrict)
flag.BoolVar(&skipVerify, "k", false, "don't verify certificates")
flag.Parse()
tcfg, err := dialer.BaselineTLSConfig(skipVerify, useStrict)
die.If(err)
args := flag.Args()
if len(args) == 1 && args[0] == "selftest" {
os.Exit(selftest())
}
if len(os.Args) < 3 {
prog := filepath.Base(os.Args[0])
fmt.Fprintf(os.Stderr, "Usage:\n %s ca.pem cert1.pem cert2.pem ...\n %s selftest\n", prog, prog)
os.Exit(2)
}
caPath := os.Args[1]
caPool, err := makePoolFromFile(caPath)
if err != nil || caPool == nil {
fmt.Fprintf(os.Stderr, "failed to load CA certificate(s): %v\n", err)
if len(args) < 2 {
fmt.Println("No certificates to check.")
os.Exit(1)
}
for _, certPath := range os.Args[2:] {
processCert(caPool, certPath)
caFile := args[0]
args = args[1:]
caCert, err := certlib.LoadCertificates(caFile)
die.If(err)
if len(caCert) != 1 {
die.With("only one CA certificate should be presented.")
}
roots := x509.NewCertPool()
roots.AddCert(caCert[0])
for _, arg := range args {
var cert *x509.Certificate
cert, err = fetch.GetCertificate(arg, tcfg)
if err != nil {
lib.Warn(err, "while parsing certificate from %s", arg)
continue
}
if bytes.Equal(cert.AuthorityKeyId, caCert[0].AuthorityKeyId) {
fmt.Printf("%s: SELF-SIGNED\n", arg)
continue
}
if _, err = verify.CertWith(cert, roots, nil, false); err != nil {
fmt.Printf("%s: INVALID\n", arg)
} else {
fmt.Printf("%s: OK (expires %s)\n", arg, cert.NotAfter.Format(lib.DateShortFormat))
}
}
}

View File

@@ -10,7 +10,7 @@ FROM golang:1.24.3-alpine AS build
WORKDIR /src
# Copy go module files and download dependencies first for better caching
RUN go install git.wntrmute.dev/kyle/goutils/cmd/cert-bundler@v1.13.1 && \
RUN go install git.wntrmute.dev/kyle/goutils/cmd/cert-bundler@v1.13.2 && \
mv /go/bin/cert-bundler /usr/local/bin/cert-bundler
# Runtime stage (kept as golang:alpine per requirement)

View File

@@ -1,67 +1,19 @@
package main
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"crypto/x509"
_ "embed"
"encoding/pem"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v2"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/certlib/bundler"
)
// Config represents the top-level YAML configuration.
type Config struct {
Config struct {
Hashes string `yaml:"hashes"`
Expiry string `yaml:"expiry"`
} `yaml:"config"`
Chains map[string]ChainGroup `yaml:"chains"`
}
// ChainGroup represents a named group of certificate chains.
type ChainGroup struct {
Certs []CertChain `yaml:"certs"`
Outputs Outputs `yaml:"outputs"`
}
// CertChain represents a root certificate and its intermediates.
type CertChain struct {
Root string `yaml:"root"`
Intermediates []string `yaml:"intermediates"`
}
// Outputs defines output format options.
type Outputs struct {
IncludeSingle bool `yaml:"include_single"`
IncludeIndividual bool `yaml:"include_individual"`
Manifest bool `yaml:"manifest"`
Formats []string `yaml:"formats"`
Encoding string `yaml:"encoding"`
}
var (
configFile string
outputDir string
)
var formatExtensions = map[string]string{
"zip": ".zip",
"tgz": ".tar.gz",
}
//go:embed README.txt
var readmeContent string
@@ -80,533 +32,10 @@ func main() {
os.Exit(1)
}
// Load and parse configuration
cfg, err := loadConfig(configFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
if err := bundler.Run(configFile, outputDir); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Parse expiry duration (default 1 year)
expiryDuration := 365 * 24 * time.Hour
if cfg.Config.Expiry != "" {
expiryDuration, err = parseDuration(cfg.Config.Expiry)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing expiry: %v\n", err)
os.Exit(1)
}
}
// Create output directory if it doesn't exist
err = os.MkdirAll(outputDir, 0750)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err)
os.Exit(1)
}
// Process each chain group
// Pre-allocate createdFiles based on total number of formats across all groups
totalFormats := 0
for _, group := range cfg.Chains {
totalFormats += len(group.Outputs.Formats)
}
createdFiles := make([]string, 0, totalFormats)
for groupName, group := range cfg.Chains {
files, perr := processChainGroup(groupName, group, expiryDuration)
if perr != nil {
fmt.Fprintf(os.Stderr, "Error processing chain group %s: %v\n", groupName, perr)
os.Exit(1)
}
createdFiles = append(createdFiles, files...)
}
// Generate hash file for all created archives
if cfg.Config.Hashes != "" {
hashFile := filepath.Join(outputDir, cfg.Config.Hashes)
if gerr := generateHashFile(hashFile, createdFiles); gerr != nil {
fmt.Fprintf(os.Stderr, "Error generating hash file: %v\n", gerr)
os.Exit(1)
}
}
fmt.Println("Certificate bundling completed successfully")
}
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if uerr := yaml.Unmarshal(data, &cfg); uerr != nil {
return nil, uerr
}
return &cfg, nil
}
func parseDuration(s string) (time.Duration, error) {
// Support simple formats like "1y", "6m", "30d"
if len(s) < 2 {
return 0, fmt.Errorf("invalid duration format: %s", s)
}
unit := s[len(s)-1]
value := s[:len(s)-1]
var multiplier time.Duration
switch unit {
case 'y', 'Y':
multiplier = 365 * 24 * time.Hour
case 'm', 'M':
multiplier = 30 * 24 * time.Hour
case 'd', 'D':
multiplier = 24 * time.Hour
default:
return time.ParseDuration(s)
}
var num int
_, err := fmt.Sscanf(value, "%d", &num)
if err != nil {
return 0, fmt.Errorf("invalid duration value: %s", s)
}
return time.Duration(num) * multiplier, nil
}
func processChainGroup(groupName string, group ChainGroup, expiryDuration time.Duration) ([]string, error) {
// Default encoding to "pem" if not specified
encoding := group.Outputs.Encoding
if encoding == "" {
encoding = "pem"
}
// Collect certificates from all chains in the group
singleFileCerts, individualCerts, err := loadAndCollectCerts(group.Certs, group.Outputs, expiryDuration)
if err != nil {
return nil, err
}
// Prepare files for inclusion in archives
archiveFiles, err := prepareArchiveFiles(singleFileCerts, individualCerts, group.Outputs, encoding)
if err != nil {
return nil, err
}
// Create archives for the entire group
createdFiles, err := createArchiveFiles(groupName, group.Outputs.Formats, archiveFiles)
if err != nil {
return nil, err
}
return createdFiles, nil
}
// loadAndCollectCerts loads all certificates from chains and collects them for processing.
func loadAndCollectCerts(
chains []CertChain,
outputs Outputs,
expiryDuration time.Duration,
) ([]*x509.Certificate, []certWithPath, error) {
var singleFileCerts []*x509.Certificate
var individualCerts []certWithPath
for _, chain := range chains {
s, i, cerr := collectFromChain(chain, outputs, expiryDuration)
if cerr != nil {
return nil, nil, cerr
}
if len(s) > 0 {
singleFileCerts = append(singleFileCerts, s...)
}
if len(i) > 0 {
individualCerts = append(individualCerts, i...)
}
}
return singleFileCerts, individualCerts, nil
}
// collectFromChain loads a single chain, performs checks, and returns the certs to include.
func collectFromChain(
chain CertChain,
outputs Outputs,
expiryDuration time.Duration,
) (
[]*x509.Certificate,
[]certWithPath,
error,
) {
var single []*x509.Certificate
var indiv []certWithPath
// Load root certificate
rootCert, rerr := certlib.LoadCertificate(chain.Root)
if rerr != nil {
return nil, nil, fmt.Errorf("failed to load root certificate %s: %w", chain.Root, rerr)
}
// Check expiry for root
checkExpiry(chain.Root, rootCert, expiryDuration)
// Add root to collections if needed
if outputs.IncludeSingle {
single = append(single, rootCert)
}
if outputs.IncludeIndividual {
indiv = append(indiv, certWithPath{cert: rootCert, path: chain.Root})
}
// Load and validate intermediates
for _, intPath := range chain.Intermediates {
intCert, lerr := certlib.LoadCertificate(intPath)
if lerr != nil {
return nil, nil, fmt.Errorf("failed to load intermediate certificate %s: %w", intPath, lerr)
}
// Validate that intermediate is signed by root
if sigErr := intCert.CheckSignatureFrom(rootCert); sigErr != nil {
return nil, nil, fmt.Errorf(
"intermediate %s is not properly signed by root %s: %w",
intPath,
chain.Root,
sigErr,
)
}
// Check expiry for intermediate
checkExpiry(intPath, intCert, expiryDuration)
// Add intermediate to collections if needed
if outputs.IncludeSingle {
single = append(single, intCert)
}
if outputs.IncludeIndividual {
indiv = append(indiv, certWithPath{cert: intCert, path: intPath})
}
}
return single, indiv, nil
}
// prepareArchiveFiles prepares all files to be included in archives.
func prepareArchiveFiles(
singleFileCerts []*x509.Certificate,
individualCerts []certWithPath,
outputs Outputs,
encoding string,
) ([]fileEntry, error) {
var archiveFiles []fileEntry
// Track used filenames to avoid collisions inside archives
usedNames := make(map[string]int)
// Handle a single bundle file
if outputs.IncludeSingle && len(singleFileCerts) > 0 {
files, err := encodeCertsToFiles(singleFileCerts, "bundle", encoding, true)
if err != nil {
return nil, fmt.Errorf("failed to encode single bundle: %w", err)
}
for i := range files {
files[i].name = makeUniqueName(files[i].name, usedNames)
}
archiveFiles = append(archiveFiles, files...)
}
// Handle individual files
if outputs.IncludeIndividual {
for _, cp := range individualCerts {
baseName := strings.TrimSuffix(filepath.Base(cp.path), filepath.Ext(cp.path))
files, err := encodeCertsToFiles([]*x509.Certificate{cp.cert}, baseName, encoding, false)
if err != nil {
return nil, fmt.Errorf("failed to encode individual cert %s: %w", cp.path, err)
}
for i := range files {
files[i].name = makeUniqueName(files[i].name, usedNames)
}
archiveFiles = append(archiveFiles, files...)
}
}
// Generate manifest if requested
if outputs.Manifest {
manifestContent := generateManifest(archiveFiles)
manifestName := makeUniqueName("MANIFEST", usedNames)
archiveFiles = append(archiveFiles, fileEntry{
name: manifestName,
content: manifestContent,
})
}
return archiveFiles, nil
}
// createArchiveFiles creates archive files in the specified formats.
func createArchiveFiles(groupName string, formats []string, archiveFiles []fileEntry) ([]string, error) {
createdFiles := make([]string, 0, len(formats))
for _, format := range formats {
ext, ok := formatExtensions[format]
if !ok {
return nil, fmt.Errorf("unsupported format: %s", format)
}
archivePath := filepath.Join(outputDir, groupName+ext)
switch format {
case "zip":
if err := createZipArchive(archivePath, archiveFiles); err != nil {
return nil, fmt.Errorf("failed to create zip archive: %w", err)
}
case "tgz":
if err := createTarGzArchive(archivePath, archiveFiles); err != nil {
return nil, fmt.Errorf("failed to create tar.gz archive: %w", err)
}
default:
return nil, fmt.Errorf("unsupported format: %s", format)
}
createdFiles = append(createdFiles, archivePath)
}
return createdFiles, nil
}
func checkExpiry(path string, cert *x509.Certificate, expiryDuration time.Duration) {
now := time.Now()
expiryThreshold := now.Add(expiryDuration)
if cert.NotAfter.Before(expiryThreshold) {
daysUntilExpiry := int(cert.NotAfter.Sub(now).Hours() / 24)
if daysUntilExpiry < 0 {
fmt.Fprintf(
os.Stderr,
"WARNING: Certificate %s has EXPIRED (expired %d days ago)\n",
path,
-daysUntilExpiry,
)
} else {
fmt.Fprintf(os.Stderr, "WARNING: Certificate %s will expire in %d days (on %s)\n", path, daysUntilExpiry, cert.NotAfter.Format("2006-01-02"))
}
}
}
type fileEntry struct {
name string
content []byte
}
type certWithPath struct {
cert *x509.Certificate
path string
}
// encodeCertsToFiles converts certificates to file entries based on encoding type
// If isSingle is true, certs are concatenated into a single file; otherwise one cert per file.
func encodeCertsToFiles(
certs []*x509.Certificate,
baseName string,
encoding string,
isSingle bool,
) ([]fileEntry, error) {
var files []fileEntry
switch encoding {
case "pem":
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
})
case "der":
if isSingle {
// For single file in DER, concatenate all cert DER bytes
var derContent []byte
for _, cert := range certs {
derContent = append(derContent, cert.Raw...)
}
files = append(files, fileEntry{
name: baseName + ".crt",
content: derContent,
})
} else if len(certs) > 0 {
// Individual DER file (should only have one cert)
files = append(files, fileEntry{
name: baseName + ".crt",
content: certs[0].Raw,
})
}
case "both":
// Add PEM version
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
})
// Add DER version
if isSingle {
var derContent []byte
for _, cert := range certs {
derContent = append(derContent, cert.Raw...)
}
files = append(files, fileEntry{
name: baseName + ".crt",
content: derContent,
})
} else if len(certs) > 0 {
files = append(files, fileEntry{
name: baseName + ".crt",
content: certs[0].Raw,
})
}
default:
return nil, fmt.Errorf("unsupported encoding: %s (must be 'pem', 'der', or 'both')", encoding)
}
return files, nil
}
// encodeCertsToPEM encodes certificates to PEM format.
func encodeCertsToPEM(certs []*x509.Certificate) []byte {
var pemContent []byte
for _, cert := range certs {
pemBlock := &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}
pemContent = append(pemContent, pem.EncodeToMemory(pemBlock)...)
}
return pemContent
}
func generateManifest(files []fileEntry) []byte {
var manifest strings.Builder
for _, file := range files {
if file.name == "MANIFEST" {
continue
}
hash := sha256.Sum256(file.content)
manifest.WriteString(fmt.Sprintf("%x %s\n", hash, file.name))
}
return []byte(manifest.String())
}
// closeWithErr attempts to close all provided closers, joining any close errors with baseErr.
func closeWithErr(baseErr error, closers ...io.Closer) error {
for _, c := range closers {
if c == nil {
continue
}
if cerr := c.Close(); cerr != nil {
baseErr = errors.Join(baseErr, cerr)
}
}
return baseErr
}
func createZipArchive(path string, files []fileEntry) error {
f, zerr := os.Create(path)
if zerr != nil {
return zerr
}
w := zip.NewWriter(f)
for _, file := range files {
fw, werr := w.Create(file.name)
if werr != nil {
return closeWithErr(werr, w, f)
}
if _, werr = fw.Write(file.content); werr != nil {
return closeWithErr(werr, w, f)
}
}
// Check errors on close operations
if cerr := w.Close(); cerr != nil {
_ = f.Close()
return cerr
}
return f.Close()
}
func createTarGzArchive(path string, files []fileEntry) error {
f, terr := os.Create(path)
if terr != nil {
return terr
}
gw := gzip.NewWriter(f)
tw := tar.NewWriter(gw)
for _, file := range files {
hdr := &tar.Header{
Name: file.name,
Mode: 0644,
Size: int64(len(file.content)),
}
if herr := tw.WriteHeader(hdr); herr != nil {
return closeWithErr(herr, tw, gw, f)
}
if _, werr := tw.Write(file.content); werr != nil {
return closeWithErr(werr, tw, gw, f)
}
}
// Check errors on close operations in the correct order
if cerr := tw.Close(); cerr != nil {
_ = gw.Close()
_ = f.Close()
return cerr
}
if cerr := gw.Close(); cerr != nil {
_ = f.Close()
return cerr
}
return f.Close()
}
func generateHashFile(path string, files []string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
for _, file := range files {
data, rerr := os.ReadFile(file)
if rerr != nil {
return rerr
}
hash := sha256.Sum256(data)
fmt.Fprintf(f, "%x %s\n", hash, filepath.Base(file))
}
return nil
}
// makeUniqueName ensures that each file name within the archive is unique by appending
// an incremental numeric suffix before the extension when collisions occur.
// Example: "root.pem" -> "root-2.pem", "root-3.pem", etc.
func makeUniqueName(name string, used map[string]int) string {
// If unused, mark and return as-is
if _, ok := used[name]; !ok {
used[name] = 1
return name
}
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
// Track a counter per base+ext key
key := base + ext
counter := max(used[key], 1)
for {
counter++
candidate := fmt.Sprintf("%s-%d%s", base, counter, ext)
if _, exists := used[candidate]; !exists {
used[key] = counter
used[candidate] = 1
return candidate
}
}
}

View File

@@ -7,7 +7,6 @@ import (
"errors"
"flag"
"fmt"
"net"
"os"
"strings"
"time"
@@ -16,6 +15,7 @@ import (
hosts "git.wntrmute.dev/kyle/goutils/certlib/hosts"
"git.wntrmute.dev/kyle/goutils/certlib/revoke"
"git.wntrmute.dev/kyle/goutils/fileutil"
"git.wntrmute.dev/kyle/goutils/lib/dialer"
)
var (
@@ -38,8 +38,10 @@ func main() {
flag.Parse()
revoke.HardFail = hardfail
// Set HTTP client timeout for revocation library
revoke.HTTPClient.Timeout = timeout
// Build a proxy-aware HTTP client for OCSP/CRL fetches
if httpClient, err := dialer.NewHTTPClient(dialer.Opts{Timeout: timeout}); err == nil {
revoke.HTTPClient = httpClient
}
if flag.NArg() == 0 {
fmt.Fprintf(os.Stderr, "Usage: %s [options] <target> [<target>...]\n", os.Args[0])
@@ -99,28 +101,19 @@ func checkSite(hostport string) (string, error) {
return strUnknown, err
}
d := &net.Dialer{Timeout: timeout}
tcfg := &tls.Config{
InsecureSkipVerify: true,
ServerName: target.Host,
} // #nosec G402 -- CLI tool only verifies revocation
td := &tls.Dialer{NetDialer: d, Config: tcfg}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
conn, err := td.DialContext(ctx, "tcp", target.String())
// Use proxy-aware TLS dialer
conn, err := dialer.DialTLS(ctx, target.String(), dialer.Opts{Timeout: timeout, TLSConfig: &tls.Config{
InsecureSkipVerify: true, // #nosec G402 -- CLI tool only verifies revocation
ServerName: target.Host,
}})
if err != nil {
return strUnknown, err
}
defer conn.Close()
tconn, ok := conn.(*tls.Conn)
if !ok {
return strUnknown, errors.New("connection is not TLS")
}
state := tconn.ConnectionState()
state := conn.ConnectionState()
if len(state.PeerCertificates) == 0 {
return strUnknown, errors.New("no peer certificates presented")
}

View File

@@ -11,6 +11,7 @@ import (
"strings"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib/dialer"
)
var hasPort = regexp.MustCompile(`:\d+$`)
@@ -23,13 +24,13 @@ func main() {
server += ":443"
}
d := &tls.Dialer{Config: &tls.Config{}} // #nosec G402
nc, err := d.DialContext(context.Background(), "tcp", server)
// Use proxy-aware TLS dialer
conn, err := dialer.DialTLS(
context.Background(),
server,
dialer.Opts{TLSConfig: &tls.Config{}},
) // #nosec G402
die.If(err)
conn, ok := nc.(*tls.Conn)
if !ok {
die.With("invalid TLS connection (not a *tls.Conn)")
}
defer conn.Close()

View File

@@ -2,364 +2,45 @@
package main
import (
"bytes"
"context"
"crypto/dsa"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"flag"
"fmt"
"io"
"os"
"sort"
"strings"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/certlib/dump"
"git.wntrmute.dev/kyle/goutils/lib"
"git.wntrmute.dev/kyle/goutils/lib/fetch"
)
func certPublic(cert *x509.Certificate) string {
switch pub := cert.PublicKey.(type) {
case *rsa.PublicKey:
return fmt.Sprintf("RSA-%d", pub.N.BitLen())
case *ecdsa.PublicKey:
switch pub.Curve {
case elliptic.P256():
return "ECDSA-prime256v1"
case elliptic.P384():
return "ECDSA-secp384r1"
case elliptic.P521():
return "ECDSA-secp521r1"
default:
return "ECDSA (unknown curve)"
}
case *dsa.PublicKey:
return "DSA"
default:
return "Unknown"
}
}
func displayName(name pkix.Name) string {
var ns []string
if name.CommonName != "" {
ns = append(ns, name.CommonName)
}
for i := range name.Country {
ns = append(ns, fmt.Sprintf("C=%s", name.Country[i]))
}
for i := range name.Organization {
ns = append(ns, fmt.Sprintf("O=%s", name.Organization[i]))
}
for i := range name.OrganizationalUnit {
ns = append(ns, fmt.Sprintf("OU=%s", name.OrganizationalUnit[i]))
}
for i := range name.Locality {
ns = append(ns, fmt.Sprintf("L=%s", name.Locality[i]))
}
for i := range name.Province {
ns = append(ns, fmt.Sprintf("ST=%s", name.Province[i]))
}
if len(ns) > 0 {
return "/" + strings.Join(ns, "/")
}
return "*** no subject information ***"
}
func keyUsages(ku x509.KeyUsage) string {
var uses []string
for u, s := range keyUsage {
if (ku & u) != 0 {
uses = append(uses, s)
}
}
sort.Strings(uses)
return strings.Join(uses, ", ")
}
func extUsage(ext []x509.ExtKeyUsage) string {
ns := make([]string, 0, len(ext))
for i := range ext {
ns = append(ns, extKeyUsages[ext[i]])
}
sort.Strings(ns)
return strings.Join(ns, ", ")
}
func showBasicConstraints(cert *x509.Certificate) {
fmt.Fprint(os.Stdout, "\tBasic constraints: ")
if cert.BasicConstraintsValid {
fmt.Fprint(os.Stdout, "valid")
} else {
fmt.Fprint(os.Stdout, "invalid")
}
if cert.IsCA {
fmt.Fprint(os.Stdout, ", is a CA certificate")
if !cert.BasicConstraintsValid {
fmt.Fprint(os.Stdout, " (basic constraint failure)")
}
} else {
fmt.Fprint(os.Stdout, "is not a CA certificate")
if cert.KeyUsage&x509.KeyUsageKeyEncipherment != 0 {
fmt.Fprint(os.Stdout, " (key encipherment usage enabled!)")
}
}
if (cert.MaxPathLen == 0 && cert.MaxPathLenZero) || (cert.MaxPathLen > 0) {
fmt.Fprintf(os.Stdout, ", max path length %d", cert.MaxPathLen)
}
fmt.Fprintln(os.Stdout)
}
const oneTrueDateFormat = "2006-01-02T15:04:05-0700"
var (
var config struct {
showHash bool
dateFormat string
showHash bool // if true, print a SHA256 hash of the certificate's Raw field
)
func wrapPrint(text string, indent int) {
tabs := ""
var tabsSb140 strings.Builder
for range indent {
tabsSb140.WriteString("\t")
}
tabs += tabsSb140.String()
fmt.Fprintf(os.Stdout, tabs+"%s\n", wrap(text, indent))
}
func displayCert(cert *x509.Certificate) {
fmt.Fprintln(os.Stdout, "CERTIFICATE")
if showHash {
fmt.Fprintln(os.Stdout, wrap(fmt.Sprintf("SHA256: %x", sha256.Sum256(cert.Raw)), 0))
}
fmt.Fprintln(os.Stdout, wrap("Subject: "+displayName(cert.Subject), 0))
fmt.Fprintln(os.Stdout, wrap("Issuer: "+displayName(cert.Issuer), 0))
fmt.Fprintf(os.Stdout, "\tSignature algorithm: %s / %s\n", sigAlgoPK(cert.SignatureAlgorithm),
sigAlgoHash(cert.SignatureAlgorithm))
fmt.Fprintln(os.Stdout, "Details:")
wrapPrint("Public key: "+certPublic(cert), 1)
fmt.Fprintf(os.Stdout, "\tSerial number: %s\n", cert.SerialNumber)
if len(cert.AuthorityKeyId) > 0 {
fmt.Fprintf(os.Stdout, "\t%s\n", wrap("AKI: "+dumpHex(cert.AuthorityKeyId), 1))
}
if len(cert.SubjectKeyId) > 0 {
fmt.Fprintf(os.Stdout, "\t%s\n", wrap("SKI: "+dumpHex(cert.SubjectKeyId), 1))
}
wrapPrint("Valid from: "+cert.NotBefore.Format(dateFormat), 1)
fmt.Fprintf(os.Stdout, "\t until: %s\n", cert.NotAfter.Format(dateFormat))
fmt.Fprintf(os.Stdout, "\tKey usages: %s\n", keyUsages(cert.KeyUsage))
if len(cert.ExtKeyUsage) > 0 {
fmt.Fprintf(os.Stdout, "\tExtended usages: %s\n", extUsage(cert.ExtKeyUsage))
}
showBasicConstraints(cert)
validNames := make([]string, 0, len(cert.DNSNames)+len(cert.EmailAddresses)+len(cert.IPAddresses))
for i := range cert.DNSNames {
validNames = append(validNames, "dns:"+cert.DNSNames[i])
}
for i := range cert.EmailAddresses {
validNames = append(validNames, "email:"+cert.EmailAddresses[i])
}
for i := range cert.IPAddresses {
validNames = append(validNames, "ip:"+cert.IPAddresses[i].String())
}
sans := fmt.Sprintf("SANs (%d): %s\n", len(validNames), strings.Join(validNames, ", "))
wrapPrint(sans, 1)
l := len(cert.IssuingCertificateURL)
if l != 0 {
var aia string
if l == 1 {
aia = "AIA"
} else {
aia = "AIAs"
}
wrapPrint(fmt.Sprintf("%d %s:", l, aia), 1)
for _, url := range cert.IssuingCertificateURL {
wrapPrint(url, 2)
}
}
l = len(cert.OCSPServer)
if l > 0 {
title := "OCSP server"
if l > 1 {
title += "s"
}
wrapPrint(title+":\n", 1)
for _, ocspServer := range cert.OCSPServer {
wrapPrint(fmt.Sprintf("- %s\n", ocspServer), 2)
}
}
}
func displayAllCerts(in []byte, leafOnly bool) {
certs, err := certlib.ParseCertificatesPEM(in)
if err != nil {
certs, _, err = certlib.ParseCertificatesDER(in, "")
if err != nil {
_, _ = lib.Warn(err, "failed to parse certificates")
return
}
}
if len(certs) == 0 {
_, _ = lib.Warnx("no certificates found")
return
}
if leafOnly {
displayCert(certs[0])
return
}
for i := range certs {
displayCert(certs[i])
}
}
func displayAllCertsWeb(uri string, leafOnly bool) {
ci := getConnInfo(uri)
d := &tls.Dialer{Config: permissiveConfig()}
nc, err := d.DialContext(context.Background(), "tcp", ci.Addr)
if err != nil {
_, _ = lib.Warn(err, "couldn't connect to %s", ci.Addr)
return
}
conn, ok := nc.(*tls.Conn)
if !ok {
_, _ = lib.Warnx("invalid TLS connection (not a *tls.Conn)")
return
}
defer conn.Close()
state := conn.ConnectionState()
if err = conn.Close(); err != nil {
_, _ = lib.Warn(err, "couldn't close TLS connection")
}
d = &tls.Dialer{Config: verifyConfig(ci.Host)}
nc, err = d.DialContext(context.Background(), "tcp", ci.Addr)
if err == nil {
conn, ok = nc.(*tls.Conn)
if !ok {
_, _ = lib.Warnx("invalid TLS connection (not a *tls.Conn)")
return
}
err = conn.VerifyHostname(ci.Host)
if err == nil {
state = conn.ConnectionState()
}
conn.Close()
} else {
_, _ = lib.Warn(err, "TLS verification error with server name %s", ci.Host)
}
if len(state.PeerCertificates) == 0 {
_, _ = lib.Warnx("no certificates found")
return
}
if leafOnly {
displayCert(state.PeerCertificates[0])
return
}
if len(state.VerifiedChains) == 0 {
_, _ = lib.Warnx("no verified chains found; using peer chain")
for i := range state.PeerCertificates {
displayCert(state.PeerCertificates[i])
}
} else {
fmt.Fprintln(os.Stdout, "TLS chain verified successfully.")
for i := range state.VerifiedChains {
fmt.Fprintf(os.Stdout, "--- Verified certificate chain %d ---%s", i+1, "\n")
for j := range state.VerifiedChains[i] {
displayCert(state.VerifiedChains[i][j])
}
}
}
}
func shouldReadStdin(argc int, argv []string) bool {
if argc == 0 {
return true
}
if argc == 1 && argv[0] == "-" {
return true
}
return false
}
func readStdin(leafOnly bool) {
certs, err := io.ReadAll(os.Stdin)
if err != nil {
_, _ = lib.Warn(err, "couldn't read certificates from standard input")
os.Exit(1)
}
// This is needed for getting certs from JSON/jq.
certs = bytes.TrimSpace(certs)
certs = bytes.ReplaceAll(certs, []byte(`\n`), []byte{0xa})
certs = bytes.Trim(certs, `"`)
displayAllCerts(certs, leafOnly)
leafOnly bool
}
func main() {
var leafOnly bool
flag.BoolVar(&showHash, "d", false, "show hashes of raw DER contents")
flag.StringVar(&dateFormat, "s", oneTrueDateFormat, "date `format` in Go time format")
flag.BoolVar(&leafOnly, "l", false, "only show the leaf certificate")
flag.BoolVar(&config.showHash, "d", false, "show hashes of raw DER contents")
flag.StringVar(&config.dateFormat, "s", lib.OneTrueDateFormat, "date `format` in Go time format")
flag.BoolVar(&config.leafOnly, "l", false, "only show the leaf certificate")
flag.Parse()
if shouldReadStdin(flag.NArg(), flag.Args()) {
readStdin(leafOnly)
return
}
tlsCfg := &tls.Config{InsecureSkipVerify: true} // #nosec G402 - tool intentionally inspects broken TLS
for _, filename := range flag.Args() {
fmt.Fprintf(os.Stdout, "--%s ---%s", filename, "\n")
if strings.HasPrefix(filename, "https://") {
displayAllCertsWeb(filename, leafOnly)
} else {
in, err := os.ReadFile(filename)
if err != nil {
_, _ = lib.Warn(err, "couldn't read certificate")
continue
}
certs, err := fetch.GetCertificateChain(filename, tlsCfg)
if err != nil {
lib.Warn(err, "couldn't read certificate")
continue
}
displayAllCerts(in, leafOnly)
if config.leafOnly {
dump.DisplayCert(os.Stdout, certs[0], config.showHash)
continue
}
for i := range certs {
dump.DisplayCert(os.Stdout, certs[i], config.showHash)
}
}
}

View File

@@ -1,189 +0,0 @@
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"strings"
"github.com/kr/text"
)
// following two lifted from CFSSL, (replace-regexp "\(.+\): \(.+\),"
// "\2: \1,")
const (
sSHA256 = "SHA256"
sSHA512 = "SHA512"
)
var keyUsage = map[x509.KeyUsage]string{
x509.KeyUsageDigitalSignature: "digital signature",
x509.KeyUsageContentCommitment: "content committment",
x509.KeyUsageKeyEncipherment: "key encipherment",
x509.KeyUsageKeyAgreement: "key agreement",
x509.KeyUsageDataEncipherment: "data encipherment",
x509.KeyUsageCertSign: "cert sign",
x509.KeyUsageCRLSign: "crl sign",
x509.KeyUsageEncipherOnly: "encipher only",
x509.KeyUsageDecipherOnly: "decipher only",
}
var extKeyUsages = map[x509.ExtKeyUsage]string{
x509.ExtKeyUsageAny: "any",
x509.ExtKeyUsageServerAuth: "server auth",
x509.ExtKeyUsageClientAuth: "client auth",
x509.ExtKeyUsageCodeSigning: "code signing",
x509.ExtKeyUsageEmailProtection: "s/mime",
x509.ExtKeyUsageIPSECEndSystem: "ipsec end system",
x509.ExtKeyUsageIPSECTunnel: "ipsec tunnel",
x509.ExtKeyUsageIPSECUser: "ipsec user",
x509.ExtKeyUsageTimeStamping: "timestamping",
x509.ExtKeyUsageOCSPSigning: "ocsp signing",
x509.ExtKeyUsageMicrosoftServerGatedCrypto: "microsoft sgc",
x509.ExtKeyUsageNetscapeServerGatedCrypto: "netscape sgc",
x509.ExtKeyUsageMicrosoftCommercialCodeSigning: "microsoft commercial code signing",
x509.ExtKeyUsageMicrosoftKernelCodeSigning: "microsoft kernel code signing",
}
func sigAlgoPK(a x509.SignatureAlgorithm) string {
switch a {
case x509.MD2WithRSA, x509.MD5WithRSA, x509.SHA1WithRSA, x509.SHA256WithRSA, x509.SHA384WithRSA, x509.SHA512WithRSA:
return "RSA"
case x509.SHA256WithRSAPSS, x509.SHA384WithRSAPSS, x509.SHA512WithRSAPSS:
return "RSA-PSS"
case x509.ECDSAWithSHA1, x509.ECDSAWithSHA256, x509.ECDSAWithSHA384, x509.ECDSAWithSHA512:
return "ECDSA"
case x509.DSAWithSHA1, x509.DSAWithSHA256:
return "DSA"
case x509.PureEd25519:
return "Ed25519"
case x509.UnknownSignatureAlgorithm:
return "unknown public key algorithm"
default:
return "unknown public key algorithm"
}
}
func sigAlgoHash(a x509.SignatureAlgorithm) string {
switch a {
case x509.MD2WithRSA:
return "MD2"
case x509.MD5WithRSA:
return "MD5"
case x509.SHA1WithRSA, x509.ECDSAWithSHA1, x509.DSAWithSHA1:
return "SHA1"
case x509.SHA256WithRSA, x509.ECDSAWithSHA256, x509.DSAWithSHA256:
return sSHA256
case x509.SHA256WithRSAPSS:
return sSHA256
case x509.SHA384WithRSA, x509.ECDSAWithSHA384:
return "SHA384"
case x509.SHA384WithRSAPSS:
return "SHA384"
case x509.SHA512WithRSA, x509.ECDSAWithSHA512:
return sSHA512
case x509.SHA512WithRSAPSS:
return sSHA512
case x509.PureEd25519:
return sSHA512
case x509.UnknownSignatureAlgorithm:
return "unknown hash algorithm"
default:
return "unknown hash algorithm"
}
}
const maxLine = 78
func makeIndent(n int) string {
s := " "
var sSb97 strings.Builder
for range n {
sSb97.WriteString(" ")
}
s += sSb97.String()
return s
}
func indentLen(n int) int {
return 4 + (8 * n)
}
// this isn't real efficient, but that's not a problem here.
func wrap(s string, indent int) string {
if indent > 3 {
indent = 3
}
wrapped := text.Wrap(s, maxLine)
lines := strings.SplitN(wrapped, "\n", 2)
if len(lines) == 1 {
return lines[0]
}
if (maxLine - indentLen(indent)) <= 0 {
panic("too much indentation")
}
rest := strings.Join(lines[1:], " ")
wrapped = text.Wrap(rest, maxLine-indentLen(indent))
return lines[0] + "\n" + text.Indent(wrapped, makeIndent(indent))
}
func dumpHex(in []byte) string {
var s string
var sSb130 strings.Builder
for i := range in {
sSb130.WriteString(fmt.Sprintf("%02X:", in[i]))
}
s += sSb130.String()
return strings.Trim(s, ":")
}
// permissiveConfig returns a maximally-accepting TLS configuration;
// the purpose is to look at the cert, not verify the security properties
// of the connection.
func permissiveConfig() *tls.Config {
return &tls.Config{
InsecureSkipVerify: true,
} // #nosec G402
}
// verifyConfig returns a config that will verify the connection.
func verifyConfig(hostname string) *tls.Config {
return &tls.Config{
ServerName: hostname,
} // #nosec G402
}
type connInfo struct {
// The original URI provided.
URI string
// The hostname of the server.
Host string
// The port to connect on.
Port string
// The address to connect to.
Addr string
}
func getConnInfo(uri string) *connInfo {
ci := &connInfo{URI: uri}
ci.Host = uri[len("https://"):]
host, port, err := net.SplitHostPort(ci.Host)
if err != nil {
ci.Port = "443"
} else {
ci.Host = host
ci.Port = port
}
ci.Addr = net.JoinHostPort(ci.Host, ci.Port)
return ci
}

View File

@@ -2,98 +2,54 @@ package main
import (
"crypto/x509"
"crypto/x509/pkix"
"flag"
"fmt"
"os"
"strings"
"time"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/certlib/verify"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
"git.wntrmute.dev/kyle/goutils/lib/dialer"
"git.wntrmute.dev/kyle/goutils/lib/fetch"
)
var warnOnly bool
var leeway = 2160 * time.Hour // three months
func displayName(name pkix.Name) string {
var ns []string
if name.CommonName != "" {
ns = append(ns, name.CommonName)
}
for i := range name.Country {
ns = append(ns, fmt.Sprintf("C=%s", name.Country[i]))
}
for i := range name.Organization {
ns = append(ns, fmt.Sprintf("O=%s", name.Organization[i]))
}
for i := range name.OrganizationalUnit {
ns = append(ns, fmt.Sprintf("OU=%s", name.OrganizationalUnit[i]))
}
for i := range name.Locality {
ns = append(ns, fmt.Sprintf("L=%s", name.Locality[i]))
}
for i := range name.Province {
ns = append(ns, fmt.Sprintf("ST=%s", name.Province[i]))
}
if len(ns) > 0 {
return "/" + strings.Join(ns, "/")
}
die.With("no subject information in root")
return ""
}
func expires(cert *x509.Certificate) time.Duration {
return time.Until(cert.NotAfter)
}
func inDanger(cert *x509.Certificate) bool {
return expires(cert) < leeway
}
func checkCert(cert *x509.Certificate) {
warn := inDanger(cert)
name := displayName(cert.Subject)
name = fmt.Sprintf("%s/SN=%s", name, cert.SerialNumber)
expiry := expires(cert)
if warnOnly {
if warn {
fmt.Fprintf(os.Stderr, "%s expires on %s (in %s)\n", name, cert.NotAfter, expiry)
}
} else {
fmt.Printf("%s expires on %s (in %s)\n", name, cert.NotAfter, expiry)
}
}
func main() {
var (
skipVerify bool
strictTLS bool
leeway = verify.DefaultLeeway
warnOnly bool
)
dialer.StrictTLSFlag(&strictTLS)
flag.BoolVar(&skipVerify, "k", false, "skip server verification") // #nosec G402
flag.BoolVar(&warnOnly, "q", false, "only warn about expiring certs")
flag.DurationVar(&leeway, "t", leeway, "warn if certificates are closer than this to expiring")
flag.Parse()
for _, file := range flag.Args() {
in, err := os.ReadFile(file)
if err != nil {
_, _ = lib.Warn(err, "failed to read file")
continue
}
tlsCfg, err := dialer.BaselineTLSConfig(skipVerify, strictTLS)
die.If(err)
certs, err := certlib.ParseCertificatesPEM(in)
for _, file := range flag.Args() {
var certs []*x509.Certificate
certs, err = fetch.GetCertificateChain(file, tlsCfg)
if err != nil {
_, _ = lib.Warn(err, "while parsing certificates")
continue
}
for _, cert := range certs {
checkCert(cert)
check := verify.NewCertCheck(cert, leeway)
if warnOnly {
if err = check.Err(); err != nil {
lib.Warn(err, "certificate is expiring")
}
} else {
fmt.Printf("%s expires on %s (in %s)\n", check.Name(),
cert.NotAfter, check.Expiry())
}
}
}
}

View File

@@ -6,9 +6,10 @@ import (
"fmt"
"strings"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
"git.wntrmute.dev/kyle/goutils/lib/dialer"
"git.wntrmute.dev/kyle/goutils/lib/fetch"
)
const displayInt lib.HexEncodeMode = iota
@@ -32,14 +33,23 @@ func serialString(cert *x509.Certificate, mode lib.HexEncodeMode) string {
}
func main() {
var skipVerify bool
var strictTLS bool
dialer.StrictTLSFlag(&strictTLS)
displayAs := flag.String("d", "int", "display mode (int, hex, uhex)")
showExpiry := flag.Bool("e", false, "show expiry date")
flag.BoolVar(&skipVerify, "k", false, "skip server verification") // #nosec G402
flag.Parse()
tlsCfg, err := dialer.BaselineTLSConfig(skipVerify, strictTLS)
die.If(err)
displayMode := parseDisplayMode(*displayAs)
for _, arg := range flag.Args() {
cert, err := certlib.LoadCertificate(arg)
var cert *x509.Certificate
cert, err = fetch.GetCertificate(arg, tlsCfg)
die.If(err)
fmt.Printf("%s: %s", arg, serialString(cert, displayMode))

View File

@@ -5,33 +5,19 @@ import (
"flag"
"fmt"
"os"
"time"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/certlib/revoke"
"git.wntrmute.dev/kyle/goutils/certlib/verify"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
"git.wntrmute.dev/kyle/goutils/lib/dialer"
)
func printRevocation(cert *x509.Certificate) {
remaining := time.Until(cert.NotAfter)
fmt.Printf("certificate expires in %s.\n", lib.Duration(remaining))
revoked, ok := revoke.VerifyCertificate(cert)
if !ok {
fmt.Fprintf(os.Stderr, "[!] the revocation check failed (failed to determine whether certificate\nwas revoked)")
return
}
if revoked {
fmt.Fprintf(os.Stderr, "[!] the certificate has been revoked\n")
return
}
}
type appConfig struct {
caFile, intFile string
forceIntermediateBundle bool
revexp, verbose bool
caFile, intFile string
forceIntermediateBundle bool
revexp, skipVerify, verbose bool
strictTLS bool
}
func parseFlags() appConfig {
@@ -40,104 +26,69 @@ func parseFlags() appConfig {
flag.StringVar(&cfg.intFile, "i", "", "intermediate `bundle`")
flag.BoolVar(&cfg.forceIntermediateBundle, "f", false,
"force the use of the intermediate bundle, ignoring any intermediates bundled with certificate")
flag.BoolVar(&cfg.skipVerify, "k", false, "skip CA verification")
flag.BoolVar(&cfg.revexp, "r", false, "print revocation and expiry information")
flag.BoolVar(&cfg.verbose, "v", false, "verbose")
dialer.StrictTLSFlag(&cfg.strictTLS)
flag.Parse()
if flag.NArg() == 0 {
die.With("usage: certverify targets...")
}
return cfg
}
func loadRoots(caFile string, verbose bool) (*x509.CertPool, error) {
if caFile == "" {
return x509.SystemCertPool()
}
if verbose {
fmt.Println("[+] loading root certificates from", caFile)
}
return certlib.LoadPEMCertPool(caFile)
}
func loadIntermediates(intFile string, verbose bool) (*x509.CertPool, error) {
if intFile == "" {
return x509.NewCertPool(), nil
}
if verbose {
fmt.Println("[+] loading intermediate certificates from", intFile)
}
// Note: use intFile here (previously used caFile mistakenly)
return certlib.LoadPEMCertPool(intFile)
}
func addBundledIntermediates(chain []*x509.Certificate, pool *x509.CertPool, verbose bool) {
for _, intermediate := range chain[1:] {
if verbose {
fmt.Printf("[+] adding intermediate with SKI %x\n", intermediate.SubjectKeyId)
}
pool.AddCert(intermediate)
}
}
func verifyCert(cert *x509.Certificate, roots, ints *x509.CertPool) error {
opts := x509.VerifyOptions{
Intermediates: ints,
Roots: roots,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}
_, err := cert.Verify(opts)
return err
}
func run(cfg appConfig) error {
roots, err := loadRoots(cfg.caFile, cfg.verbose)
if err != nil {
return err
}
ints, err := loadIntermediates(cfg.intFile, cfg.verbose)
if err != nil {
return err
}
if flag.NArg() != 1 {
fmt.Fprintf(os.Stderr, "Usage: %s [-ca bundle] [-i bundle] cert", lib.ProgName())
}
fileData, err := os.ReadFile(flag.Arg(0))
if err != nil {
return err
}
chain, err := certlib.ParseCertificatesPEM(fileData)
if err != nil {
return err
}
if cfg.verbose {
fmt.Printf("[+] %s has %d certificates\n", flag.Arg(0), len(chain))
}
cert := chain[0]
if len(chain) > 1 && !cfg.forceIntermediateBundle {
addBundledIntermediates(chain, ints, cfg.verbose)
}
if err = verifyCert(cert, roots, ints); err != nil {
return fmt.Errorf("certificate verification failed: %w", err)
}
if cfg.verbose {
fmt.Println("OK")
}
if cfg.revexp {
printRevocation(cert)
}
return nil
}
func main() {
var (
roots, ints *x509.CertPool
err error
failed bool
)
cfg := parseFlags()
if err := run(cfg); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
opts := &verify.Opts{
CheckRevocation: cfg.revexp,
ForceIntermediates: cfg.forceIntermediateBundle,
Verbose: cfg.verbose,
}
if cfg.caFile != "" {
if cfg.verbose {
fmt.Printf("loading CA certificates from %s\n", cfg.caFile)
}
roots, err = certlib.LoadPEMCertPool(cfg.caFile)
die.If(err)
}
if cfg.intFile != "" {
if cfg.verbose {
fmt.Printf("loading intermediate certificates from %s\n", cfg.intFile)
}
ints, err = certlib.LoadPEMCertPool(cfg.intFile)
die.If(err)
}
opts.Config, err = dialer.BaselineTLSConfig(cfg.skipVerify, cfg.strictTLS)
die.If(err)
opts.Config.RootCAs = roots
opts.Intermediates = ints
for _, arg := range flag.Args() {
_, err = verify.Chain(os.Stdout, arg, opts)
if err != nil {
lib.Warn(err, "while verifying %s", arg)
failed = true
} else {
fmt.Printf("%s: OK\n", arg)
}
}
if failed {
os.Exit(1)
}
}

View File

@@ -3,18 +3,31 @@ kgz
kgz is like gzip, but supports compressing and decompressing to a different
directory than the source file is in.
Usage: kgz [-l] source [target]
Usage: kgz [-l] [-k] [-m] [-x] [--uid N] [--gid N] source [target]
If target is a directory, the basename of the sourcefile will be used
If target is a directory, the basename of the source file will be used
as the target filename. Compression and decompression is selected
based on whether the source filename ends in ".gz".
Flags:
-l level Compression level (0-9). Only meaninful when
compressing a file.
-l level Compression level (0-9). Only meaningful when compressing.
-u Do not restrict the size during decompression. As
a safeguard against gzip bombs, the maximum size
allowed is 32 * the compressed file size.
-k Keep the source file (do not remove it after successful
compression or decompression).
-m On decompression, set the file mtime from the gzip header.
-x On compression, include uid/gid/mode/ctime in the gzip Extra
field so that decompression can restore them. The Extra payload
is an ASN.1 DER-encoded struct.
--uid N When used with -x, set UID in Extra to N (override source).
--gid N When used with -x, set GID in Extra to N (override source).
Metadata notes:
- mtime is stored in the standard gzip header and restored with -m.
- uid/gid/mode/ctime are stored in a kgz-specific Extra subfield as an ASN.1
DER-encoded struct. Restoring
uid/gid may fail without sufficient privileges; such errors are ignored.

View File

@@ -3,23 +3,178 @@ package main
import (
"compress/flate"
"compress/gzip"
"encoding/asn1"
"encoding/binary"
"flag"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
goutilslib "git.wntrmute.dev/kyle/goutils/lib"
)
const gzipExt = ".gz"
func compress(path, target string, level int) error {
// kgzExtraID is the two-byte subfield identifier used in the gzip Extra field
// for kgz-specific metadata.
var kgzExtraID = [2]byte{'K', 'G'}
// buildKGExtra constructs the gzip Extra subfield payload for kgz metadata.
//
// The payload is an ASN.1 DER-encoded struct with the following fields:
//
// Version INTEGER (currently 1)
// UID INTEGER
// GID INTEGER
// Mode INTEGER (permission bits)
// CTimeSec INTEGER (seconds)
// CTimeNSec INTEGER (nanoseconds)
//
// The ASN.1 blob is wrapped in a gzip Extra subfield with ID 'K','G'.
func buildKGExtra(uid, gid, mode uint32, ctimeS int64, ctimeNs int32) []byte {
// Define the ASN.1 structure to encode
type KGZExtra struct {
Version int
UID int
GID int
Mode int
CTimeSec int64
CTimeNSec int32
}
payload, err := asn1.Marshal(KGZExtra{
Version: 1,
UID: int(uid),
GID: int(gid),
Mode: int(mode),
CTimeSec: ctimeS,
CTimeNSec: ctimeNs,
})
if err != nil {
// On marshal failure, return empty to avoid breaking compression
return nil
}
// Wrap in gzip subfield: [ID1 ID2 LEN(lo) LEN(hi) PAYLOAD]
// Guard against payload length overflow to uint16 for the extra subfield length.
if len(payload) > int(math.MaxUint16) {
return nil
}
extra := make([]byte, 4+len(payload))
extra[0] = kgzExtraID[0]
extra[1] = kgzExtraID[1]
binary.LittleEndian.PutUint16(extra[2:], uint16(len(payload)&0xFFFF)) //#nosec G115 - masked
copy(extra[4:], payload)
return extra
}
// clampToInt32 clamps an int value into the int32 range using a switch to
// satisfy linters that prefer switch over if-else chains for ordered checks.
func clampToInt32(v int) int32 {
switch {
case v > int(math.MaxInt32):
return math.MaxInt32
case v < int(math.MinInt32):
return math.MinInt32
default:
return int32(v)
}
}
// buildExtraForPath prepares the gzip Extra field for kgz by collecting
// uid/gid/mode and ctime information, applying any overrides, and encoding it.
func buildExtraForPath(st unix.Stat_t, path string, setUID, setGID int) []byte {
uid := st.Uid
gid := st.Gid
if setUID >= 0 {
if uint64(setUID) <= math.MaxUint32 {
uid = uint32(setUID & 0xFFFFFFFF) //#nosec G115 - masked
}
}
if setGID >= 0 {
if uint64(setGID) <= math.MaxUint32 {
gid = uint32(setGID & 0xFFFFFFFF) //#nosec G115 - masked
}
}
mode := uint32(st.Mode & 0o7777)
// Use portable helper to gather ctime
var cts int64
var ctns int32
if ft, err := goutilslib.LoadFileTime(path); err == nil {
cts = ft.Changed.Unix()
ctns = clampToInt32(ft.Changed.Nanosecond())
}
return buildKGExtra(uid, gid, mode, cts, ctns)
}
// parseKGExtra scans a gzip Extra blob and returns kgz metadata if present.
func parseKGExtra(extra []byte) (uint32, uint32, uint32, int64, int32, bool) {
i := 0
for i+4 <= len(extra) {
id1 := extra[i]
id2 := extra[i+1]
l := int(binary.LittleEndian.Uint16(extra[i+2 : i+4]))
i += 4
if i+l > len(extra) {
break
}
if id1 == kgzExtraID[0] && id2 == kgzExtraID[1] {
// ASN.1 decode payload
payload := extra[i : i+l]
var s struct {
Version int
UID int
GID int
Mode int
CTimeSec int64
CTimeNSec int32
}
if _, err := asn1.Unmarshal(payload, &s); err != nil {
return 0, 0, 0, 0, 0, false
}
if s.Version != 1 {
return 0, 0, 0, 0, 0, false
}
// Validate ranges before converting from int -> uint32 to avoid overflow.
if s.UID < 0 || s.GID < 0 || s.Mode < 0 {
return 0, 0, 0, 0, 0, false
}
if uint64(s.UID) > math.MaxUint32 || uint64(s.GID) > math.MaxUint32 || uint64(s.Mode) > math.MaxUint32 {
return 0, 0, 0, 0, 0, false
}
return uint32(s.UID & 0xFFFFFFFF), uint32(s.GID & 0xFFFFFFFF),
uint32(s.Mode & 0xFFFFFFFF), s.CTimeSec, s.CTimeNSec, true //#nosec G115 - masked
}
i += l
}
return 0, 0, 0, 0, 0, false
}
func compress(path, target string, level int, includeExtra bool, setUID, setGID int) error {
sourceFile, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening file for read: %w", err)
}
defer sourceFile.Close()
// Gather file metadata
var st unix.Stat_t
if err = unix.Stat(path, &st); err != nil {
return fmt.Errorf("stat source: %w", err)
}
fi, err := sourceFile.Stat()
if err != nil {
return fmt.Errorf("stat source file: %w", err)
}
destFile, err := os.Create(target)
if err != nil {
return fmt.Errorf("opening file for write: %w", err)
@@ -30,6 +185,11 @@ func compress(path, target string, level int) error {
if err != nil {
return fmt.Errorf("invalid compression level: %w", err)
}
// Set header metadata
gzipCompressor.ModTime = fi.ModTime()
if includeExtra {
gzipCompressor.Extra = buildExtraForPath(st, path, setUID, setGID)
}
defer gzipCompressor.Close()
_, err = io.Copy(gzipCompressor, sourceFile)
@@ -40,7 +200,7 @@ func compress(path, target string, level int) error {
return nil
}
func uncompress(path, target string, unrestrict bool) error {
func uncompress(path, target string, unrestrict bool, preserveMtime bool) error {
sourceFile, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening file for read: %w", err)
@@ -79,19 +239,40 @@ func uncompress(path, target string, unrestrict bool) error {
if err != nil {
return fmt.Errorf("uncompressing file: %w", err)
}
// Apply metadata from Extra (uid/gid/mode) if present
if gzipUncompressor.Header.Extra != nil {
if uid, gid, mode, _, _, ok := parseKGExtra(gzipUncompressor.Header.Extra); ok {
// Chmod
_ = os.Chmod(target, os.FileMode(mode))
// Chown (may fail without privileges)
_ = os.Chown(target, int(uid), int(gid))
}
}
// Preserve mtime if requested
if preserveMtime {
mt := gzipUncompressor.Header.ModTime
if !mt.IsZero() {
// Set both atime and mtime to mt for simplicity
_ = os.Chtimes(target, mt, mt)
}
}
return nil
}
func usage(w io.Writer) {
fmt.Fprintf(w, `Usage: %s [-l] source [target]
fmt.Fprintf(w, `Usage: %s [-l] [-k] [-m] [-x] [--uid N] [--gid N] source [target]
kgz is like gzip, but supports compressing and decompressing to a different
directory than the source file is in.
Flags:
-l level Compression level (0-9). Only meaninful when
compressing a file.
-l level Compression level (0-9). Only meaningful when compressing.
-u Do not restrict the size during decompression (gzip bomb guard is 32x).
-k Keep the source file (do not remove it after successful (de)compression).
-m On decompression, set the file mtime from the gzip header.
-x On compression, include uid/gid/mode/ctime in the gzip Extra field.
--uid N When used with -x, set UID in Extra to N (overrides source owner).
--gid N When used with -x, set GID in Extra to N (overrides source group).
`, os.Args[0])
}
@@ -150,9 +331,19 @@ func main() {
var target = "."
var err error
var unrestrict bool
var keep bool
var preserveMtime bool
var includeExtra bool
var setUID int
var setGID int
flag.IntVar(&level, "l", flate.DefaultCompression, "compression level")
flag.BoolVar(&unrestrict, "u", false, "do not restrict decompression")
flag.BoolVar(&keep, "k", false, "keep the source file (do not remove it)")
flag.BoolVar(&preserveMtime, "m", false, "on decompression, set mtime from gzip header")
flag.BoolVar(&includeExtra, "x", false, "on compression, include uid/gid/mode/ctime in gzip Extra")
flag.IntVar(&setUID, "uid", -1, "when used with -x, set UID in Extra to this value")
flag.IntVar(&setGID, "gid", -1, "when used with -x, set GID in Extra to this value")
flag.Parse()
if flag.NArg() < 1 || flag.NArg() > 2 {
@@ -172,12 +363,15 @@ func main() {
os.Exit(1)
}
err = uncompress(path, target, unrestrict)
err = uncompress(path, target, unrestrict, preserveMtime)
if err != nil {
os.Remove(target)
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
if !keep {
_ = os.Remove(path)
}
return
}
@@ -187,10 +381,13 @@ func main() {
os.Exit(1)
}
err = compress(path, target, level)
err = compress(path, target, level, includeExtra, setUID, setGID)
if err != nil {
os.Remove(target)
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
if !keep {
_ = os.Remove(path)
}
}

View File

@@ -9,10 +9,12 @@ import (
"net/url"
"os"
"path/filepath"
"time"
"git.wntrmute.dev/kyle/goutils/ahash"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
"git.wntrmute.dev/kyle/goutils/lib/dialer"
)
func usage(w io.Writer) {
@@ -82,8 +84,13 @@ func main() {
_, _ = lib.Warn(reqErr, "building request for %s", remote)
continue
}
client := &http.Client{}
resp, err := client.Do(req)
// Use proxy-aware HTTP client with a reasonable timeout for connects/handshakes
httpClient, err := dialer.NewHTTPClient(dialer.Opts{Timeout: 30 * time.Second})
if err != nil {
_, _ = lib.Warn(err, "building HTTP client for %s", remote)
continue
}
resp, err := httpClient.Do(req)
if err != nil {
_, _ = lib.Warn(err, "fetching %s", remote)
continue

View File

@@ -1,30 +1,19 @@
package main
import (
"bytes"
"crypto/ecdsa"
"crypto/rsa"
"crypto/sha1" // #nosec G505
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
// #nosec G505
"flag"
"fmt"
"io"
"os"
"strings"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/certlib/ski"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
)
const (
keyTypeRSA = "RSA"
keyTypeECDSA = "ECDSA"
)
func usage(w io.Writer) {
fmt.Fprintf(w, `ski: print subject key info for PEM-encoded files
@@ -32,10 +21,10 @@ Usage:
ski [-hm] files...
Flags:
-d Hex encoding mode.
-h Print this help message.
-m All SKIs should match; as soon as an SKI mismatch is found,
it is reported.
`)
}
@@ -43,156 +32,37 @@ func init() {
flag.Usage = func() { usage(os.Stderr) }
}
func parse(path string) ([]byte, string, string) {
data, err := os.ReadFile(path)
die.If(err)
data = bytes.TrimSpace(data)
p, rest := pem.Decode(data)
if len(rest) > 0 {
_, _ = lib.Warnx("trailing data in PEM file")
}
if p == nil {
die.With("no PEM data found")
}
data = p.Bytes
var (
public []byte
kt string
ft string
)
switch p.Type {
case "PRIVATE KEY", "RSA PRIVATE KEY", "EC PRIVATE KEY":
public, kt = parseKey(data)
ft = "private key"
case "CERTIFICATE":
public, kt = parseCertificate(data)
ft = "certificate"
case "CERTIFICATE REQUEST":
public, kt = parseCSR(data)
ft = "certificate request"
default:
die.With("unknown PEM type %s", p.Type)
}
return public, kt, ft
}
func parseKey(data []byte) ([]byte, string) {
priv, err := certlib.ParsePrivateKeyDER(data)
if err != nil {
die.If(err)
}
var kt string
switch priv.Public().(type) {
case *rsa.PublicKey:
kt = keyTypeRSA
case *ecdsa.PublicKey:
kt = keyTypeECDSA
default:
die.With("unknown private key type %T", priv)
}
public, err := x509.MarshalPKIXPublicKey(priv.Public())
die.If(err)
return public, kt
}
func parseCertificate(data []byte) ([]byte, string) {
cert, err := x509.ParseCertificate(data)
die.If(err)
pub := cert.PublicKey
var kt string
switch pub.(type) {
case *rsa.PublicKey:
kt = keyTypeRSA
case *ecdsa.PublicKey:
kt = keyTypeECDSA
default:
die.With("unknown public key type %T", pub)
}
public, err := x509.MarshalPKIXPublicKey(pub)
die.If(err)
return public, kt
}
func parseCSR(data []byte) ([]byte, string) {
// Use certlib to support both PEM and DER and to centralize validation.
csr, _, err := certlib.ParseCSR(data)
die.If(err)
pub := csr.PublicKey
var kt string
switch pub.(type) {
case *rsa.PublicKey:
kt = keyTypeRSA
case *ecdsa.PublicKey:
kt = keyTypeECDSA
default:
die.With("unknown public key type %T", pub)
}
public, err := x509.MarshalPKIXPublicKey(pub)
die.If(err)
return public, kt
}
func dumpHex(in []byte) string {
var s string
var sSb153 strings.Builder
for i := range in {
sSb153.WriteString(fmt.Sprintf("%02X:", in[i]))
}
s += sSb153.String()
return strings.Trim(s, ":")
}
type subjectPublicKeyInfo struct {
Algorithm pkix.AlgorithmIdentifier
SubjectPublicKey asn1.BitString
}
func main() {
var help, shouldMatch bool
var displayModeString string
flag.StringVar(&displayModeString, "d", "lower", "hex encoding mode")
flag.BoolVar(&help, "h", false, "print a help message and exit")
flag.BoolVar(&shouldMatch, "m", false, "all SKIs should match")
flag.Parse()
displayMode := lib.ParseHexEncodeMode(displayModeString)
if help {
usage(os.Stdout)
os.Exit(0)
}
var ski string
var matchSKI string
for _, path := range flag.Args() {
public, kt, ft := parse(path)
keyInfo, err := ski.ParsePEM(path)
die.If(err)
var subPKI subjectPublicKeyInfo
_, err := asn1.Unmarshal(public, &subPKI)
if err != nil {
_, _ = lib.Warn(err, "failed to get subject PKI")
continue
keySKI, err := keyInfo.SKI(displayMode)
die.If(err)
if matchSKI == "" {
matchSKI = keySKI
}
pubHash := sha1.Sum(subPKI.SubjectPublicKey.Bytes) // #nosec G401 this is the standard
pubHashString := dumpHex(pubHash[:])
if ski == "" {
ski = pubHashString
}
if shouldMatch && ski != pubHashString {
if shouldMatch && matchSKI != keySKI {
_, _ = lib.Warnx("%s: SKI mismatch (%s != %s)",
path, ski, pubHashString)
path, matchSKI, keySKI)
}
fmt.Printf("%s %s (%s %s)\n", path, pubHashString, kt, ft)
fmt.Printf("%s %s (%s %s)\n", path, keySKI, keyInfo.KeyType, keyInfo.FileType)
}
}

View File

@@ -3,55 +3,48 @@ package main
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"flag"
"fmt"
"net"
"os"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib/dialer"
)
func main() {
var cfg = &tls.Config{} // #nosec G402
var sysRoot, serverName string
var skipVerify bool
var strictTLS bool
dialer.StrictTLSFlag(&strictTLS)
flag.StringVar(&sysRoot, "ca", "", "provide an alternate CA bundle")
flag.StringVar(&cfg.ServerName, "sni", cfg.ServerName, "provide an SNI name")
flag.BoolVar(&cfg.InsecureSkipVerify, "noverify", false, "don't verify certificates")
flag.StringVar(&serverName, "sni", "", "provide an SNI name")
flag.BoolVar(&skipVerify, "noverify", false, "don't verify certificates")
flag.Parse()
tlsCfg, err := dialer.BaselineTLSConfig(skipVerify, strictTLS)
die.If(err)
if sysRoot != "" {
pemList, err := os.ReadFile(sysRoot)
tlsCfg.RootCAs, err = certlib.LoadPEMCertPool(sysRoot)
die.If(err)
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(pemList) {
fmt.Printf("[!] no valid roots found")
roots = nil
}
cfg.RootCAs = roots
}
if serverName != "" {
cfg.ServerName = serverName
tlsCfg.ServerName = serverName
}
for _, site := range flag.Args() {
_, _, err := net.SplitHostPort(site)
_, _, err = net.SplitHostPort(site)
if err != nil {
site += ":443"
}
d := &tls.Dialer{Config: cfg}
nc, err := d.DialContext(context.Background(), "tcp", site)
die.If(err)
conn, ok := nc.(*tls.Conn)
if !ok {
die.With("invalid TLS connection (not a *tls.Conn)")
}
var conn *tls.Conn
conn, err = dialer.DialTLS(context.Background(), site, dialer.Opts{TLSConfig: tlsCfg})
die.If(err)
cs := conn.ConnectionState()
var chain []byte

View File

@@ -9,6 +9,7 @@ import (
"git.wntrmute.dev/kyle/goutils/certlib/hosts"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib/dialer"
)
func main() {
@@ -20,18 +21,14 @@ func main() {
hostPort, err := hosts.ParseHost(os.Args[1])
die.If(err)
d := &tls.Dialer{Config: &tls.Config{
InsecureSkipVerify: true,
}} // #nosec G402
nc, err := d.DialContext(context.Background(), "tcp", hostPort.String())
// Use proxy-aware TLS dialer; skip verification as before
conn, err := dialer.DialTLS(
context.Background(),
hostPort.String(),
dialer.Opts{TLSConfig: &tls.Config{InsecureSkipVerify: true}},
) // #nosec G402
die.If(err)
conn, ok := nc.(*tls.Conn)
if !ok {
die.With("invalid TLS connection (not a *tls.Conn)")
}
defer conn.Close()
state := conn.ConnectionState()

View File

@@ -1,14 +1,6 @@
package main
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"flag"
"fmt"
"os"
@@ -17,123 +9,7 @@ import (
"git.wntrmute.dev/kyle/goutils/die"
)
var validPEMs = map[string]bool{
"PRIVATE KEY": true,
"RSA PRIVATE KEY": true,
"EC PRIVATE KEY": true,
}
const (
curveInvalid = iota // any invalid curve
curveRSA // indicates key is an RSA key, not an EC key
curveP256
curveP384
curveP521
)
func getECCurve(pub any) int {
switch pub := pub.(type) {
case *ecdsa.PublicKey:
switch pub.Curve {
case elliptic.P256():
return curveP256
case elliptic.P384():
return curveP384
case elliptic.P521():
return curveP521
default:
return curveInvalid
}
case *rsa.PublicKey:
return curveRSA
default:
return curveInvalid
}
}
// matchRSA compares an RSA public key from certificate against RSA public key from private key.
// It returns true on match.
func matchRSA(certPub *rsa.PublicKey, keyPub *rsa.PublicKey) bool {
return keyPub.N.Cmp(certPub.N) == 0 && keyPub.E == certPub.E
}
// matchECDSA compares ECDSA public keys for equality and compatible curve.
// It returns match=true when they are on the same curve and have the same X/Y.
// If curves mismatch, match is false.
func matchECDSA(certPub *ecdsa.PublicKey, keyPub *ecdsa.PublicKey) bool {
if getECCurve(certPub) != getECCurve(keyPub) {
return false
}
if keyPub.X.Cmp(certPub.X) != 0 {
return false
}
if keyPub.Y.Cmp(certPub.Y) != 0 {
return false
}
return true
}
// matchKeys determines whether the certificate's public key matches the given private key.
// It returns true if they match; otherwise, it returns false and a human-friendly reason.
func matchKeys(cert *x509.Certificate, priv crypto.Signer) (bool, string) {
switch keyPub := priv.Public().(type) {
case *rsa.PublicKey:
switch certPub := cert.PublicKey.(type) {
case *rsa.PublicKey:
if matchRSA(certPub, keyPub) {
return true, ""
}
return false, "public keys don't match"
case *ecdsa.PublicKey:
return false, "RSA private key, EC public key"
default:
return false, fmt.Sprintf("unsupported certificate public key type: %T", cert.PublicKey)
}
case *ecdsa.PublicKey:
switch certPub := cert.PublicKey.(type) {
case *ecdsa.PublicKey:
if matchECDSA(certPub, keyPub) {
return true, ""
}
// Determine a more precise reason
kc := getECCurve(keyPub)
cc := getECCurve(certPub)
if kc == curveInvalid {
return false, "invalid private key curve"
}
if cc == curveRSA {
return false, "private key is EC, certificate is RSA"
}
if kc != cc {
return false, "EC curves don't match"
}
return false, "public keys don't match"
case *rsa.PublicKey:
return false, "private key is EC, certificate is RSA"
default:
return false, fmt.Sprintf("unsupported certificate public key type: %T", cert.PublicKey)
}
default:
return false, fmt.Sprintf("unrecognised private key type: %T", priv.Public())
}
}
func loadKey(path string) (crypto.Signer, error) {
in, err := os.ReadFile(path)
if err != nil {
return nil, err
}
in = bytes.TrimSpace(in)
if p, _ := pem.Decode(in); p != nil {
if !validPEMs[p.Type] {
return nil, errors.New("invalid private key file type " + p.Type)
}
return certlib.ParsePrivateKeyPEM(in)
}
return certlib.ParsePrivateKeyDER(in)
}
// functionality refactored into certlib
func main() {
var keyFile, certFile string
@@ -141,23 +17,13 @@ func main() {
flag.StringVar(&certFile, "c", "", "TLS `certificate` file")
flag.Parse()
in, err := os.ReadFile(certFile)
cert, err := certlib.LoadCertificate(certFile)
die.If(err)
p, _ := pem.Decode(in)
if p != nil {
if p.Type != "CERTIFICATE" {
die.With("invalid certificate (type is %s)", p.Type)
}
in = p.Bytes
}
cert, err := x509.ParseCertificate(in)
priv, err := certlib.LoadPrivateKey(keyFile)
die.If(err)
priv, err := loadKey(keyFile)
die.If(err)
matched, reason := matchKeys(cert, priv)
matched, reason := certlib.MatchKeys(cert, priv)
if matched {
fmt.Println("Match.")
return

View File

@@ -1,12 +1,34 @@
// Package dbg implements a debug printer.
// Package dbg implements a simple debug printer.
//
// There are two main ways to use it:
// - By using one of the constructors and calling flag.BoolVar(&debug.Enabled...)
// - By setting the environment variable GOUTILS_ENABLE_DEBUG to true or false and
// calling NewFromEnv().
//
// If enabled, any of the print statements will be written to stdout. Otherwise,
// nothing will be emitted.
package dbg
import (
"fmt"
"io"
"os"
"runtime/debug"
"strings"
)
const DebugEnvKey = "GOUTILS_ENABLE_DEBUG"
var enabledValues = map[string]bool{
"1": true,
"true": true,
"yes": true,
"on": true,
"y": true,
"enable": true,
"enabled": true,
}
// A DebugPrinter is a drop-in replacement for fmt.Print*, and also acts as
// an io.WriteCloser when enabled.
type DebugPrinter struct {
@@ -15,6 +37,23 @@ type DebugPrinter struct {
out io.WriteCloser
}
// New returns a new DebugPrinter on os.Stdout.
func New() *DebugPrinter {
return &DebugPrinter{
out: os.Stderr,
}
}
// NewFromEnv returns a new DebugPrinter based on the value of the environment
// variable GOUTILS_ENABLE_DEBUG.
func NewFromEnv() *DebugPrinter {
enabled := strings.ToLower(os.Getenv(DebugEnvKey))
return &DebugPrinter{
out: os.Stderr,
Enabled: enabledValues[enabled],
}
}
// Close satisfies the Closer interface.
func (dbg *DebugPrinter) Close() error {
return dbg.out.Close()
@@ -28,13 +67,6 @@ func (dbg *DebugPrinter) Write(p []byte) (int, error) {
return 0, nil
}
// New returns a new DebugPrinter on os.Stdout.
func New() *DebugPrinter {
return &DebugPrinter{
out: os.Stdout,
}
}
// ToFile sets up a new DebugPrinter to a file, truncating it if it exists.
func ToFile(path string) (*DebugPrinter, error) {
file, err := os.Create(path)
@@ -74,3 +106,7 @@ func (dbg *DebugPrinter) Printf(format string, v ...any) {
fmt.Fprintf(dbg.out, format, v...)
}
}
func (dbg *DebugPrinter) StackTrace() {
dbg.Write(debug.Stack())
}

5
go.mod
View File

@@ -6,7 +6,8 @@ require (
github.com/hashicorp/go-syslog v1.0.0
github.com/kr/text v0.2.0
github.com/pkg/sftp v1.12.0
golang.org/x/crypto v0.44.0
golang.org/x/crypto v0.39.0
golang.org/x/net v0.38.0
golang.org/x/sys v0.38.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -15,6 +16,7 @@ require (
github.com/benbjohnson/clock v1.3.5
github.com/davecgh/go-spew v1.1.1
github.com/google/certificate-transparency-go v1.0.21
rsc.io/qr v0.2.0
)
require (
@@ -22,5 +24,4 @@ require (
github.com/kr/pretty v0.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
rsc.io/qr v0.2.0 // indirect
)

6
go.sum
View File

@@ -27,13 +27,19 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@@ -11,3 +11,11 @@ const (
// ExitFailure is the failing exit status.
ExitFailure = 1
)
const (
OneTrueDateFormat = "2006-01-02T15:04:05-0700"
DateShortFormat = "2006-01-02"
TimeShortFormat = "15:04:05"
TimeShorterFormat = "15:04"
TimeStandardDateTime = "2006-01-02 15:04"
)

525
lib/dialer/dialer.go Normal file
View File

@@ -0,0 +1,525 @@
// Package dialer provides proxy-aware dialers for plain TCP and TLS
// connections using environment variables.
//
// Supported proxy environment variables (checked case-insensitively):
// - SOCKS5_PROXY (e.g., socks5://user:pass@host:1080)
// - HTTPS_PROXY (e.g., https://user:pass@host:443)
// - HTTP_PROXY (e.g., http://user:pass@host:3128)
//
// Precedence when multiple proxies are set (both for net and TLS dialers):
// 1. SOCKS5_PROXY
// 2. HTTPS_PROXY
// 3. HTTP_PROXY
//
// Both uppercase and lowercase variable names are honored.
package dialer
import (
"bufio"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"flag"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
xproxy "golang.org/x/net/proxy"
"git.wntrmute.dev/kyle/goutils/dbg"
)
// StrictBaselineTLSConfig returns a secure TLS config.
// Many of the tools in this repo are designed to debug broken TLS systems
// and therefore explicitly support old or insecure TLS setups.
func StrictBaselineTLSConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: false, // explicitly set
}
}
func StrictTLSFlag(useStrict *bool) {
flag.BoolVar(useStrict, "strict-tls", false, "Use strict TLS configuration (disables certificate verification)")
}
func BaselineTLSConfig(skipVerify bool, secure bool) (*tls.Config, error) {
if secure && skipVerify {
return nil, errors.New("cannot skip verification and use secure TLS")
}
if skipVerify {
return &tls.Config{InsecureSkipVerify: true}, nil // #nosec G402 - intentional
}
if secure {
return StrictBaselineTLSConfig(), nil
}
return &tls.Config{}, nil // #nosec G402 - intentional
}
var debug = dbg.NewFromEnv()
// Opts controls creation of proxy-aware dialers.
//
// Timeout controls the maximum amount of time spent establishing the
// underlying TCP connection and any proxy handshake. If zero, a
// reasonable default (30s) is used.
//
// TLSConfig is used by the TLS dialer to configure the TLS handshake to
// the target endpoint. If TLSConfig.ServerName is empty, it will be set
// from the host portion of the address passed to DialContext.
type Opts struct {
Timeout time.Duration
TLSConfig *tls.Config
}
// ContextDialer matches the common DialContext signature used by net and tls dialers.
type ContextDialer interface {
DialContext(ctx context.Context, network, address string) (net.Conn, error)
}
// DialTCP is a convenience helper that dials a TCP connection to address
// using a proxy-aware dialer derived from opts. It honors SOCKS5_PROXY,
// HTTPS_PROXY, and HTTP_PROXY environment variables.
func DialTCP(ctx context.Context, address string, opts Opts) (net.Conn, error) {
d, err := NewNetDialer(opts)
if err != nil {
return nil, err
}
return d.DialContext(ctx, "tcp", address)
}
// DialTLS is a convenience helper that dials a TLS-wrapped TCP connection to
// address using a proxy-aware dialer derived from opts. It returns a *tls.Conn.
// It honors SOCKS5_PROXY, HTTPS_PROXY, and HTTP_PROXY environment variables and
// uses opts.TLSConfig for the handshake (filling ServerName from address if empty).
func DialTLS(ctx context.Context, address string, opts Opts) (*tls.Conn, error) {
d, err := NewTLSDialer(opts)
if err != nil {
return nil, err
}
c, err := d.DialContext(ctx, "tcp", address)
if err != nil {
return nil, err
}
tlsConn, ok := c.(*tls.Conn)
if !ok {
_ = c.Close()
return nil, fmt.Errorf("DialTLS: expected *tls.Conn, got %T", c)
}
return tlsConn, nil
}
// NewNetDialer returns a ContextDialer that dials TCP connections using
// proxies discovered from the environment (SOCKS5_PROXY, HTTPS_PROXY, HTTP_PROXY).
// The returned dialer supports context cancellation for direct and HTTP(S)
// proxies and applies the configured timeout to connection/proxy handshake.
func NewNetDialer(opts Opts) (ContextDialer, error) {
if opts.Timeout <= 0 {
opts.Timeout = 30 * time.Second
}
if u := getProxyURLFromEnv("SOCKS5_PROXY"); u != nil {
debug.Printf("using SOCKS5 proxy %q\n", u)
return newSOCKS5Dialer(u, opts)
}
if u := getProxyURLFromEnv("HTTPS_PROXY"); u != nil {
// Respect the proxy URL scheme. Zscaler may set HTTPS_PROXY to an HTTP proxy
// running locally; in that case we must NOT TLS-wrap the proxy connection.
debug.Printf("using HTTPS proxy %q\n", u)
return &httpProxyDialer{
proxyURL: u,
timeout: opts.Timeout,
secure: strings.EqualFold(u.Scheme, "https"),
config: opts.TLSConfig,
}, nil
}
if u := getProxyURLFromEnv("HTTP_PROXY"); u != nil {
debug.Printf("using HTTP proxy %q\n", u)
return &httpProxyDialer{
proxyURL: u,
timeout: opts.Timeout,
// Only TLS-wrap the proxy connection if the URL scheme is https.
secure: strings.EqualFold(u.Scheme, "https"),
config: opts.TLSConfig,
}, nil
}
// Direct dialer
return &net.Dialer{Timeout: opts.Timeout}, nil
}
// NewTLSDialer returns a ContextDialer that establishes a TLS connection to
// the destination, while honoring SOCKS5_PROXY/HTTPS_PROXY/HTTP_PROXY.
//
// The returned dialer performs proxy negotiation (if any), then completes a
// TLS handshake to the target using opts.TLSConfig.
func NewTLSDialer(opts Opts) (ContextDialer, error) {
if opts.Timeout <= 0 {
opts.Timeout = 30 * time.Second
}
// Prefer SOCKS5 if present.
if u := getProxyURLFromEnv("SOCKS5_PROXY"); u != nil {
debug.Printf("using SOCKS5 proxy %q\n", u)
base, err := newSOCKS5Dialer(u, opts)
if err != nil {
return nil, err
}
return &tlsWrappingDialer{base: base, tcfg: opts.TLSConfig, timeout: opts.Timeout}, nil
}
// For TLS, prefer HTTPS proxy over HTTP if both set.
if u := getProxyURLFromEnv("HTTPS_PROXY"); u != nil {
debug.Printf("using HTTPS proxy %q\n", u)
base := &httpProxyDialer{
proxyURL: u,
timeout: opts.Timeout,
secure: strings.EqualFold(u.Scheme, "https"),
config: opts.TLSConfig,
}
return &tlsWrappingDialer{base: base, tcfg: opts.TLSConfig, timeout: opts.Timeout}, nil
}
if u := getProxyURLFromEnv("HTTP_PROXY"); u != nil {
debug.Printf("using HTTP proxy %q\n", u)
base := &httpProxyDialer{
proxyURL: u,
timeout: opts.Timeout,
secure: strings.EqualFold(u.Scheme, "https"),
config: opts.TLSConfig,
}
return &tlsWrappingDialer{base: base, tcfg: opts.TLSConfig, timeout: opts.Timeout}, nil
}
// Direct TLS
base := &net.Dialer{Timeout: opts.Timeout}
return &tlsWrappingDialer{base: base, tcfg: opts.TLSConfig, timeout: opts.Timeout}, nil
}
// ---- Implementation helpers ----
func getProxyURLFromEnv(name string) *url.URL {
// check both upper/lowercase
v := os.Getenv(name)
if v == "" {
v = os.Getenv(strings.ToLower(name))
}
if v == "" {
return nil
}
// If scheme omitted, infer from env var name.
if !strings.Contains(v, "://") {
switch strings.ToUpper(name) {
case "SOCKS5_PROXY":
v = "socks5://" + v
case "HTTPS_PROXY":
v = "https://" + v
default:
v = "http://" + v
}
}
u, err := url.Parse(v)
if err != nil {
return nil
}
return u
}
// NewHTTPClient returns an *http.Client that is proxy-aware.
//
// Behavior:
// - If SOCKS5_PROXY is set, the client routes all TCP connections through the
// SOCKS5 proxy using a custom DialContext, and disables HTTP(S) proxying in
// the transport (per our precedence SOCKS5 > HTTPS > HTTP).
// - Otherwise, it uses http.ProxyFromEnvironment which supports HTTP_PROXY,
// HTTPS_PROXY, and NO_PROXY/no_proxy.
// - Connection and TLS handshake timeouts are derived from opts.Timeout.
// - For HTTPS targets, opts.TLSConfig is applied to the transport.
func NewHTTPClient(opts Opts) (*http.Client, error) {
if opts.Timeout <= 0 {
opts.Timeout = 30 * time.Second
}
// Base transport configuration
tr := &http.Transport{
TLSClientConfig: opts.TLSConfig,
TLSHandshakeTimeout: opts.Timeout,
// Leave other fields as Go defaults for compatibility.
}
// If SOCKS5 is configured, use our dialer and disable HTTP proxying to
// avoid double-proxying. Otherwise, rely on ProxyFromEnvironment for
// HTTP(S) proxies and still set a connect timeout via net.Dialer.
if u := getProxyURLFromEnv("SOCKS5_PROXY"); u != nil {
d, err := newSOCKS5Dialer(u, opts)
if err != nil {
return nil, err
}
tr.Proxy = nil
tr.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
return d.DialContext(ctx, network, address)
}
} else {
tr.Proxy = http.ProxyFromEnvironment
// Use a standard net.Dialer to ensure we apply a connect timeout.
nd := &net.Dialer{Timeout: opts.Timeout}
tr.DialContext = nd.DialContext
}
// Construct client; we don't set Client.Timeout here to avoid affecting
// streaming responses. Callers can set it if they want an overall deadline.
return &http.Client{Transport: tr}, nil
}
// httpProxyDialer implements CONNECT tunneling over HTTP or HTTPS proxy.
type httpProxyDialer struct {
proxyURL *url.URL
timeout time.Duration
secure bool // true for HTTPS proxy
config *tls.Config
}
// proxyAddress returns host:port for the proxy, applying defaults by scheme when missing.
func (d *httpProxyDialer) proxyAddress() string {
proxyAddr := d.proxyURL.Host
if !strings.Contains(proxyAddr, ":") {
if d.secure {
proxyAddr += ":443"
} else {
proxyAddr += ":80"
}
}
return proxyAddr
}
// tlsWrapProxyConn performs a TLS handshake to the proxy when d.secure is true.
// It clones the provided tls.Config (if any), ensures ServerName and a safe
// minimum TLS version.
func (d *httpProxyDialer) tlsWrapProxyConn(ctx context.Context, conn net.Conn) (net.Conn, error) {
host := d.proxyURL.Hostname()
// Clone provided config (if any) to avoid mutating caller's config.
cfg := &tls.Config{} // #nosec G402 - intentional
if d.config != nil {
cfg = d.config.Clone()
}
if cfg.ServerName == "" {
cfg.ServerName = host
}
tlsConn := tls.Client(conn, cfg)
if err := tlsConn.HandshakeContext(ctx); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("tls handshake with https proxy failed: %w", err)
}
return tlsConn, nil
}
// readConnectResponse reads and validates the proxy's response to a CONNECT
// request. It returns nil on a 200 status and an error otherwise.
func readConnectResponse(br *bufio.Reader) error {
statusLine, err := br.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read CONNECT response: %w", err)
}
if !strings.HasPrefix(statusLine, "HTTP/") {
return fmt.Errorf("invalid proxy response: %q", strings.TrimSpace(statusLine))
}
if !strings.Contains(statusLine, " 200 ") && !strings.HasSuffix(strings.TrimSpace(statusLine), " 200") {
// Drain headers for context
_ = drainHeaders(br)
return fmt.Errorf("proxy CONNECT failed: %s", strings.TrimSpace(statusLine))
}
return drainHeaders(br)
}
func (d *httpProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
if !strings.HasPrefix(network, "tcp") {
return nil, fmt.Errorf("http proxy dialer only supports TCP, got %q", network)
}
// Dial to proxy
var nd = &net.Dialer{Timeout: d.timeout}
conn, err := nd.DialContext(ctx, "tcp", d.proxyAddress())
if err != nil {
return nil, err
}
// Deadline covering CONNECT and (for TLS wrapper) will be handled by caller too.
if d.timeout > 0 {
_ = conn.SetDeadline(time.Now().Add(d.timeout))
}
// If HTTPS proxy, wrap with TLS to the proxy itself.
if d.secure {
c, werr := d.tlsWrapProxyConn(ctx, conn)
if werr != nil {
return nil, werr
}
conn = c
}
req := buildConnectRequest(d.proxyURL, address)
if _, err = conn.Write([]byte(req)); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("failed to write CONNECT request: %w", err)
}
// Read proxy response until end of headers
br := bufio.NewReader(conn)
if err = readConnectResponse(br); err != nil {
_ = conn.Close()
return nil, err
}
// Clear deadline for caller to manage further I/O.
_ = conn.SetDeadline(time.Time{})
return conn, nil
}
func buildConnectRequest(proxyURL *url.URL, target string) string {
var b strings.Builder
fmt.Fprintf(&b, "CONNECT %s HTTP/1.1\r\n", target)
fmt.Fprintf(&b, "Host: %s\r\n", target)
b.WriteString("Proxy-Connection: Keep-Alive\r\n")
b.WriteString("User-Agent: goutils-dialer/1\r\n")
if proxyURL.User != nil {
user := proxyURL.User.Username()
pass, _ := proxyURL.User.Password()
auth := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
fmt.Fprintf(&b, "Proxy-Authorization: Basic %s\r\n", auth)
}
b.WriteString("\r\n")
return b.String()
}
func drainHeaders(br *bufio.Reader) error {
for {
line, err := br.ReadString('\n')
if err != nil {
return fmt.Errorf("reading proxy headers: %w", err)
}
if line == "\r\n" || line == "\n" {
return nil
}
}
}
// newSOCKS5Dialer builds a context-aware wrapper over the x/net/proxy dialer.
func newSOCKS5Dialer(u *url.URL, opts Opts) (ContextDialer, error) {
var auth *xproxy.Auth
if u.User != nil {
user := u.User.Username()
pass, _ := u.User.Password()
auth = &xproxy.Auth{User: user, Password: pass}
}
forward := &net.Dialer{Timeout: opts.Timeout}
d, err := xproxy.SOCKS5("tcp", hostPortWithDefault(u, "1080"), auth, forward)
if err != nil {
return nil, err
}
return &socks5ContextDialer{d: d, timeout: opts.Timeout}, nil
}
type socks5ContextDialer struct {
d xproxy.Dialer // lacks context; we wrap it
timeout time.Duration
}
func (s *socks5ContextDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
if !strings.HasPrefix(network, "tcp") {
return nil, errors.New("socks5 dialer only supports TCP")
}
// Best-effort context support: run the non-context dial in a goroutine
// and respect ctx cancellation/timeout.
type result struct {
c net.Conn
err error
}
ch := make(chan result, 1)
go func() {
c, err := s.d.Dial("tcp", address)
ch <- result{c: c, err: err}
}()
select {
case <-ctx.Done():
return nil, ctx.Err()
case r := <-ch:
return r.c, r.err
}
}
// tlsWrappingDialer performs a TLS handshake over an existing base dialer.
type tlsWrappingDialer struct {
base ContextDialer
tcfg *tls.Config
timeout time.Duration
}
func (t *tlsWrappingDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
if !strings.HasPrefix(network, "tcp") {
return nil, fmt.Errorf("tls dialer only supports TCP, got %q", network)
}
raw, err := t.base.DialContext(ctx, network, address)
if err != nil {
return nil, err
}
// Apply deadline for handshake.
if t.timeout > 0 {
_ = raw.SetDeadline(time.Now().Add(t.timeout))
}
var h string
host := address
if h, _, err = net.SplitHostPort(address); err == nil {
host = h
}
var cfg *tls.Config
if t.tcfg != nil {
// Clone to avoid copying internal locks and to prevent mutating caller's config.
c := t.tcfg.Clone()
if c.ServerName == "" {
c.ServerName = host
}
cfg = c
} else {
cfg = &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
}
tlsConn := tls.Client(raw, cfg)
if err = tlsConn.HandshakeContext(ctx); err != nil {
_ = raw.Close()
return nil, err
}
// Clear deadline after successful handshake
_ = tlsConn.SetDeadline(time.Time{})
return tlsConn, nil
}
func hostPortWithDefault(u *url.URL, defPort string) string {
host := u.Host
if !strings.Contains(host, ":") {
host += ":" + defPort
}
return host
}

1
lib/duration/duration.go Normal file
View File

@@ -0,0 +1 @@
package duration

167
lib/fetch/fetch.go Normal file
View File

@@ -0,0 +1,167 @@
package fetch
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net"
"os"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/certlib/hosts"
"git.wntrmute.dev/kyle/goutils/fileutil"
"git.wntrmute.dev/kyle/goutils/lib"
"git.wntrmute.dev/kyle/goutils/lib/dialer"
)
// Note: Previously this package exposed a FetcherOpts type. It has been
// refactored to use *tls.Config directly for configuring TLS behavior.
// Fetcher is an interface for fetching certificates from a remote source. It
// currently supports fetching from a server or a file.
type Fetcher interface {
Get() (*x509.Certificate, error)
GetChain() ([]*x509.Certificate, error)
String() string
}
type ServerFetcher struct {
host string
port int
insecure bool
roots *x509.CertPool
}
// WithRoots sets the roots for the ServerFetcher.
func WithRoots(roots *x509.CertPool) func(*ServerFetcher) {
return func(sf *ServerFetcher) {
sf.roots = roots
}
}
// WithSkipVerify sets the insecure flag for the ServerFetcher.
func WithSkipVerify() func(*ServerFetcher) {
return func(sf *ServerFetcher) {
sf.insecure = true
}
}
// ParseServer parses a server string into a ServerFetcher. It can be a URL or a
// a host:port pair.
func ParseServer(host string) (*ServerFetcher, error) {
target, err := hosts.ParseHost(host)
if err != nil {
return nil, fmt.Errorf("failed to parse server: %w", err)
}
return &ServerFetcher{
host: target.Host,
port: target.Port,
}, nil
}
func (sf *ServerFetcher) String() string {
return fmt.Sprintf("tls://%s", net.JoinHostPort(sf.host, lib.Itoa(sf.port, -1)))
}
func (sf *ServerFetcher) GetChain() ([]*x509.Certificate, error) {
opts := dialer.Opts{
TLSConfig: &tls.Config{
InsecureSkipVerify: sf.insecure, // #nosec G402 - no shit sherlock
RootCAs: sf.roots,
},
}
conn, err := dialer.DialTLS(context.Background(), net.JoinHostPort(sf.host, lib.Itoa(sf.port, -1)), opts)
if err != nil {
return nil, fmt.Errorf("failed to dial server: %w", err)
}
defer conn.Close()
state := conn.ConnectionState()
return state.PeerCertificates, nil
}
func (sf *ServerFetcher) Get() (*x509.Certificate, error) {
certs, err := sf.GetChain()
if err != nil {
return nil, err
}
return certs[0], nil
}
type FileFetcher struct {
path string
}
func NewFileFetcher(path string) *FileFetcher {
return &FileFetcher{
path: path,
}
}
func (ff *FileFetcher) String() string {
return ff.path
}
func (ff *FileFetcher) GetChain() ([]*x509.Certificate, error) {
if ff.path == "-" {
certData, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, fmt.Errorf("failed to read from stdin: %w", err)
}
return certlib.ParseCertificatesPEM(certData)
}
certs, err := certlib.LoadCertificates(ff.path)
if err != nil {
return nil, fmt.Errorf("failed to load chain: %w", err)
}
return certs, nil
}
func (ff *FileFetcher) Get() (*x509.Certificate, error) {
certs, err := ff.GetChain()
if err != nil {
return nil, err
}
return certs[0], nil
}
// GetCertificateChain fetches a certificate chain from a remote source.
// If cfg is non-nil and spec refers to a TLS server, the provided TLS
// configuration will be used to control verification behavior (e.g.,
// InsecureSkipVerify, RootCAs).
func GetCertificateChain(spec string, cfg *tls.Config) ([]*x509.Certificate, error) {
if fileutil.FileDoesExist(spec) {
return NewFileFetcher(spec).GetChain()
}
fetcher, err := ParseServer(spec)
if err != nil {
return nil, err
}
if cfg != nil {
fetcher.insecure = cfg.InsecureSkipVerify
fetcher.roots = cfg.RootCAs
}
return fetcher.GetChain()
}
// GetCertificate fetches the first certificate from a certificate chain.
func GetCertificate(spec string, cfg *tls.Config) (*x509.Certificate, error) {
certs, err := GetCertificateChain(spec, cfg)
if err != nil {
return nil, err
}
return certs[0], nil
}

View File

@@ -1,11 +1,13 @@
// Package lib contains functions useful for most programs.
package lib
import (
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
@@ -112,6 +114,88 @@ func Duration(d time.Duration) string {
return s
}
// IsDigit checks if a byte is a decimal digit.
func IsDigit(b byte) bool {
return b >= '0' && b <= '9'
}
const signedaMask64 = 1<<63 - 1
// ParseDuration parses a duration string into a time.Duration.
// It supports standard units (ns, us/µs, ms, s, m, h) plus extended units:
// d (days, 24h), w (weeks, 7d), y (years, 365d).
// Units can be combined without spaces, e.g., "1y2w3d4h5m6s".
// Case-insensitive. Years and days are approximations (no leap seconds/months).
// Returns an error for invalid input.
func ParseDuration(s string) (time.Duration, error) {
s = strings.ToLower(s) // Normalize to lowercase for case-insensitivity.
if s == "" {
return 0, errors.New("empty duration string")
}
var total time.Duration
i := 0
for i < len(s) {
// Parse the number part.
start := i
for i < len(s) && IsDigit(s[i]) {
i++
}
if start == i {
return 0, fmt.Errorf("expected number at position %d", start)
}
numStr := s[start:i]
num, err := strconv.ParseUint(numStr, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid number %q: %w", numStr, err)
}
// Parse the unit part.
if i >= len(s) {
return 0, fmt.Errorf("expected unit after number %q", numStr)
}
unitStart := i
i++ // Consume the first char of the unit.
unit := s[unitStart:i]
// Handle potential two-char units like "ms".
if unit == "m" && i < len(s) && s[i] == 's' {
i++ // Consume the 's'.
unit = "ms"
}
// Convert to duration based on unit.
var d time.Duration
switch unit {
case "ns":
d = time.Nanosecond * time.Duration(num&signedaMask64) // #nosec G115 - masked off
case "us", "µs":
d = time.Microsecond * time.Duration(num&signedaMask64) // #nosec G115 - masked off
case "ms":
d = time.Millisecond * time.Duration(num&signedaMask64) // #nosec G115 - masked off
case "s":
d = time.Second * time.Duration(num&signedaMask64) // #nosec G115 - masked off
case "m":
d = time.Minute * time.Duration(num&signedaMask64) // #nosec G115 - masked off
case "h":
d = time.Hour * time.Duration(num&signedaMask64) // #nosec G115 - masked off
case "d":
d = 24 * time.Hour * time.Duration(num&signedaMask64) // #nosec G115 - masked off
case "w":
d = 7 * 24 * time.Hour * time.Duration(num&signedaMask64) // #nosec G115 - masked off
case "y":
// Approximate, non-leap year.
d = 365 * 24 * time.Hour * time.Duration(num&signedaMask64) // #nosec G115 - masked off;
default:
return 0, fmt.Errorf("unknown unit %q at position %d", s[unitStart:i], unitStart)
}
total += d
}
return total, nil
}
type HexEncodeMode uint8
const (
@@ -125,6 +209,10 @@ const (
// HexEncodeUpperColon prints the bytes as uppercase hexadecimal
// with colons between each pair of bytes.
HexEncodeUpperColon
// HexEncodeBytes prints the string as a sequence of []byte.
HexEncodeBytes
// HexEncodeBase64 prints the string as a base64-encoded string.
HexEncodeBase64
)
func (m HexEncodeMode) String() string {
@@ -137,6 +225,10 @@ func (m HexEncodeMode) String() string {
return "lcolon"
case HexEncodeUpperColon:
return "ucolon"
case HexEncodeBytes:
return "bytes"
case HexEncodeBase64:
return "base64"
default:
panic("invalid hex encode mode")
}
@@ -152,6 +244,10 @@ func ParseHexEncodeMode(s string) HexEncodeMode {
return HexEncodeLowerColon
case "ucolon":
return HexEncodeUpperColon
case "bytes":
return HexEncodeBytes
case "base64":
return HexEncodeBase64
}
panic("invalid hex encode mode")
@@ -202,19 +298,33 @@ func hexEncode(b []byte) string {
return s
}
// HexEncode encodes the given bytes as a hexadecimal string.
func HexEncode(b []byte, mode HexEncodeMode) string {
str := hexEncode(b)
func bytesAsByteSliceString(buf []byte) string {
sb := &strings.Builder{}
sb.WriteString("[]byte{")
for i := range buf {
fmt.Fprintf(sb, "0x%02x, ", buf[i])
}
sb.WriteString("}")
return sb.String()
}
// HexEncode encodes the given bytes as a hexadecimal string. It
// also supports a few other binary-encoding formats as well.
func HexEncode(b []byte, mode HexEncodeMode) string {
switch mode {
case HexEncodeLower:
return str
return hexEncode(b)
case HexEncodeUpper:
return strings.ToUpper(str)
return strings.ToUpper(hexEncode(b))
case HexEncodeLowerColon:
return hexColons(str)
return hexColons(hexEncode(b))
case HexEncodeUpperColon:
return strings.ToUpper(hexColons(str))
return strings.ToUpper(hexColons(hexEncode(b)))
case HexEncodeBytes:
return bytesAsByteSliceString(b)
case HexEncodeBase64:
return base64.StdEncoding.EncodeToString(b)
default:
panic("invalid hex encode mode")
}

View File

@@ -2,10 +2,46 @@ package lib_test
import (
"testing"
"time"
"git.wntrmute.dev/kyle/goutils/lib"
)
func TestParseDuration(t *testing.T) {
tests := []struct {
name string
input string
expected time.Duration
wantErr bool
}{
// Valid durations
{"hour", "1h", time.Hour, false},
{"day", "2d", 2 * 24 * time.Hour, false},
{"minute", "3m", 3 * time.Minute, false},
{"second", "4s", 4 * time.Second, false},
// Edge cases
{"zero seconds", "0s", 0, false},
{"empty string", "", 0, true},
{"no numeric before unit", "h", 0, true},
{"invalid unit", "1x", 0, true},
{"non-numeric input", "abc", 0, true},
{"missing unit", "10", 0, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := lib.ParseDuration(tc.input)
if (err != nil) != tc.wantErr {
t.Fatalf("unexpected error: %v, wantErr: %v", err, tc.wantErr)
}
if got != tc.expected {
t.Fatalf("expected %v, got %v", tc.expected, got)
}
})
}
}
func TestHexEncode_LowerUpper(t *testing.T) {
b := []byte{0x0f, 0xa1, 0x00, 0xff}

70
release-docker.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Release Docker image for kisom/goutils using the Dockerfile in the repo root.
#
# Behavior:
# - Determines the git tag that points to HEAD. If no tag points to HEAD, aborts.
# - Builds the Docker image from the top-level Dockerfile.
# - Tags the image as kisom/goutils:<TAG> and kisom/goutils:latest.
# - Pushes both tags.
#
# Usage:
# ./release-docker.sh
set -euo pipefail
err() { printf "Error: %s\n" "$*" >&2; }
info() { printf "==> %s\n" "$*"; }
# Ensure we're inside a git repository and operate from the repo root.
if ! REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null); then
err "This script must be run within a git repository."
exit 1
fi
cd "$REPO_ROOT"
IMAGE_REPO="kisom/goutils"
DOCKERFILE_PATH="$REPO_ROOT/Dockerfile"
if [[ ! -f "$DOCKERFILE_PATH" ]]; then
err "Dockerfile not found at repository root: $DOCKERFILE_PATH"
err "Create a top-level Dockerfile or adjust this script before releasing."
exit 1
fi
# Find tags that point to HEAD.
if ! TAGS=$(git tag --points-at HEAD); then
err "Unable to query git tags."
exit 1
fi
if [[ -z "$TAGS" ]]; then
err "No git tag points at HEAD. Aborting release."
exit 1
fi
# Use the first tag if multiple are present; warn the user.
# Avoid readarray for broader Bash compatibility (e.g., macOS Bash 3.2).
TAG_ARRAY=("$TAGS")
TAG="${TAG_ARRAY[0]}"
if (( ${#TAG_ARRAY[@]} > 1 )); then
info "Multiple tags point at HEAD: ${TAG_ARRAY[*]}"
info "Using first tag: $TAG"
fi
info "Releasing Docker image for tag: $TAG"
IMAGE_TAGGED="$IMAGE_REPO:$TAG"
IMAGE_LATEST="$IMAGE_REPO:latest"
info "Building image from $DOCKERFILE_PATH"
docker build -f "$DOCKERFILE_PATH" -t "$IMAGE_TAGGED" -t "$IMAGE_LATEST" "$REPO_ROOT"
info "Pushing $IMAGE_TAGGED"
docker push "$IMAGE_TAGGED"
info "Pushing $IMAGE_LATEST"
docker push "$IMAGE_LATEST"
info "Release complete."