Compare commits

...

41 Commits

Author SHA1 Message Date
80b3376fa5 Update CHANGELOG for v1.15.7. 2025-11-19 14:50:22 -08:00
603724c2c9 cmd/tlsinfo: fix typo in output. 2025-11-19 14:48:02 -08:00
85de524a02 certlib/certgen: GenerateKey was generating wrong key type.
The ed25519 block was being used to generate RSA keys.
2025-11-19 14:46:54 -08:00
02fb85aec0 certlib: update FileKind with algo information.
Additionally, key algo wasn't being set on PEM files.
2025-11-19 14:46:17 -08:00
b1a2039c7d Update CHANGELOG for v1.15.6. 2025-11-19 09:46:09 -08:00
46c9976e73 certlib: Add file kind functionality. 2025-11-19 09:45:57 -08:00
5a5dd5e6ea Update CHANGELOG for v1.15.5. 2025-11-19 08:45:08 -08:00
3317b8c33b certlib/bundler: add support for pemcrt. 2025-11-19 08:43:46 -08:00
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
55 changed files with 3166 additions and 1149 deletions

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.

View File

@@ -419,14 +419,9 @@ builds:
archives:
- formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`.
# archive filename: name_version_os_arch
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
@@ -439,15 +434,9 @@ changelog:
- "^docs:"
- "^test:"
gitea_urls:
api: https://git.wntrmute.dev/api/v1
download: https://git.wntrmute.dev/
# set to true if you use a self-signed certificate
skip_tls_verify: false
release:
github:
owner: kyle
owner: kisom
name: goutils
footer: >-

126
CHANGELOG
View File

@@ -1,5 +1,131 @@
CHANGELOG
v1.15.7 - 2025-11-19
Changed:
- certlib: update FileKind with algo information and fix bug where PEM
files didn't have their algorithm set.
- certlib/certgen: GenerateKey had the blocks for Ed25519 and RSA keys
swapped.
- cmd/tlsinfo: fix type in output.
v1.15.6 - 2025-11-19
certlib: add FileKind function to determine file type.
v1.15.5 - 2025-11-19
certlib/bundler: add support for crt files that are pem-encoded.
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:

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

@@ -422,6 +422,24 @@ func encodeCertsToFiles(
name: baseName + ".pem",
content: pemContent,
})
case "crt":
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".crt",
content: pemContent,
})
case "pemcrt":
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
})
pemContent = encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".crt",
content: pemContent,
})
case "der":
if isSingle {
// For single file in DER, concatenate all cert DER bytes

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.Ed25519:
pub, key, err = ed25519.GenerateKey(rand.Reader)
case x509.RSA:
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,10 +1,19 @@
package certlib
import (
"bytes"
"crypto"
"crypto/dsa"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"strings"
"git.wntrmute.dev/kyle/goutils/certlib/certerr"
)
@@ -12,6 +21,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 +33,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 +103,194 @@ 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})
}
type FileFormat uint8
const (
FormatPEM FileFormat = iota + 1
FormatDER
)
func (f FileFormat) String() string {
switch f {
case FormatPEM:
return "PEM"
case FormatDER:
return "DER"
default:
return "unknown"
}
}
type KeyAlgo struct {
Type x509.PublicKeyAlgorithm
Size int
curve elliptic.Curve
}
func (ka KeyAlgo) String() string {
switch ka.Type {
case x509.RSA:
return fmt.Sprintf("RSA-%d", ka.Size)
case x509.ECDSA:
return fmt.Sprintf("ECDSA-%s", ka.curve.Params().Name)
case x509.Ed25519:
return "Ed25519"
case x509.DSA:
return "DSA"
default:
return "unknown"
}
}
func publicKeyAlgoFromPublicKey(key crypto.PublicKey) KeyAlgo {
switch key := key.(type) {
case *rsa.PublicKey:
return KeyAlgo{
Type: x509.RSA,
Size: key.N.BitLen(),
}
case *ecdsa.PublicKey:
return KeyAlgo{
Type: x509.ECDSA,
curve: key.Curve,
Size: key.Params().BitSize,
}
case *ed25519.PublicKey:
return KeyAlgo{
Type: x509.Ed25519,
}
case *dsa.PublicKey:
return KeyAlgo{
Type: x509.DSA,
}
default:
return KeyAlgo{
Type: x509.UnknownPublicKeyAlgorithm,
}
}
}
func publicKeyAlgoFromKey(key crypto.PrivateKey) KeyAlgo {
switch key := key.(type) {
case *rsa.PrivateKey:
return KeyAlgo{
Type: x509.RSA,
Size: key.PublicKey.N.BitLen(),
}
case *ecdsa.PrivateKey:
return KeyAlgo{
Type: x509.ECDSA,
curve: key.PublicKey.Curve,
Size: key.Params().BitSize,
}
case *ed25519.PrivateKey:
return KeyAlgo{
Type: x509.Ed25519,
}
case *dsa.PrivateKey:
return KeyAlgo{
Type: x509.DSA,
}
default:
return KeyAlgo{
Type: x509.UnknownPublicKeyAlgorithm,
}
}
}
func publicKeyAlgoFromCert(cert *x509.Certificate) KeyAlgo {
return publicKeyAlgoFromPublicKey(cert.PublicKey)
}
func publicKeyAlgoFromCSR(csr *x509.CertificateRequest) KeyAlgo {
return publicKeyAlgoFromPublicKey(csr.PublicKeyAlgorithm)
}
type FileType struct {
Format FileFormat
Type string
Algo KeyAlgo
}
func (ft FileType) String() string {
if ft.Type == "" {
return ft.Format.String()
}
return fmt.Sprintf("%s %s (%s)", ft.Algo, ft.Type, ft.Format)
}
// FileKind returns the file type of the given file.
func FileKind(path string) (*FileType, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
ft := &FileType{Format: FormatDER}
block, _ := pem.Decode(data)
if block != nil {
data = block.Bytes
ft.Type = strings.ToLower(block.Type)
ft.Format = FormatPEM
}
cert, err := x509.ParseCertificate(data)
if err == nil {
ft.Algo = publicKeyAlgoFromCert(cert)
return ft, nil
}
csr, err := x509.ParseCertificateRequest(data)
if err == nil {
ft.Algo = publicKeyAlgoFromCSR(csr)
return ft, nil
}
priv, err := x509.ParsePKCS8PrivateKey(data)
if err == nil {
ft.Algo = publicKeyAlgoFromKey(priv)
return ft, nil
}
return nil, errors.New("certlib; unknown file type")
}

View File

@@ -2,7 +2,10 @@
package certlib
import (
"crypto/elliptic"
"crypto/x509"
"fmt"
"strings"
"testing"
"git.wntrmute.dev/kyle/goutils/assert"
@@ -138,3 +141,33 @@ func TestReadCertificates(t *testing.T) {
assert.BoolT(t, cert != nil, "lib: expected an actual certificate to have been returned")
}
}
var (
ecTestCACert = "testdata/ec-ca-cert.pem"
ecTestCAPriv = "testdata/ec-ca-priv.pem"
ecTestCAReq = "testdata/ec-ca-cert.csr"
)
func TestFileTypeEC(t *testing.T) {
ft, err := FileKind(ecTestCAPriv)
assert.NoErrorT(t, err)
if ft.Format != FormatPEM {
t.Errorf("certlib: expected format '%s', got '%s'", FormatPEM, ft.Format)
}
if ft.Type != strings.ToLower(pemTypePrivateKey) {
t.Errorf("certlib: expected type '%s', got '%s'",
strings.ToLower(pemTypePrivateKey), ft.Type)
}
expectedAlgo := KeyAlgo{
Type: x509.ECDSA,
Size: 521,
curve: elliptic.P521(),
}
if ft.Algo.String() != expectedAlgo.String() {
t.Errorf("certlib: expected algo '%s', got '%s'", expectedAlgo, ft.Algo)
}
}

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

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 (

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-----

12
certlib/testdata/ec-ca-cert.csr vendored Normal file
View File

@@ -0,0 +1,12 @@
-----BEGIN CERTIFICATE REQUEST-----
MIIBzTCCAS4CAQAwgYgxCzAJBgNVBAYTAlVTMQkwBwYDVQQIEwAxCTAHBgNVBAcT
ADEiMCAGA1UEChMZV05UUk1VVEUgSEVBVlkgSU5EVVNUUklFUzEfMB0GA1UECxMW
Q1JZUFRPR1JBUEhJQyBTRVJWSUNFUzEeMBwGA1UEAxMVV05UUk1VVEUgVEVTVCBF
QyBDQSAxMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQAQxmTxzo1XOK0HDrtn92b
exC4sXr8GnU+oATiXied3e1AWVOux9XtaWduY+a+r6Kb1rxMVyebn9KqtwNw+9KS
XaEB1IN9QzfdxEcJgRIAVtFplOqCip5xKK0B+woo3wXm3ndq2kJts86aONqQ0m2g
RrsmAKAX4pwmMnAHFF7veBcpsqugADAKBggqhkjOPQQDBAOBjAAwgYgCQgDG8Hdu
FkC3z0u0MU01+Bi/2MorcVTvdkurLm6Rh2Zf65aaXK8PDdV/cPZ98qx7NoLDSvwF
83gJuUI/3nVB/Ith7wJCAb6SAkXroT7y41XHayyTYb6+RKSlxxb9e5rtVCp/nG23
s59r23vUC/wDb4VWJE5jKi5vmXfjY+RAL9FOnpr2wsX0
-----END CERTIFICATE REQUEST-----

18
certlib/testdata/ec-ca-cert.pem vendored Normal file
View File

@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC4TCCAkKgAwIBAgIUSnrCuvU8kj0nxNzmTgibiPLrQ8QwCgYIKoZIzj0EAwQw
gYgxCzAJBgNVBAYTAlVTMQkwBwYDVQQIEwAxCTAHBgNVBAcTADEiMCAGA1UEChMZ
V05UUk1VVEUgSEVBVlkgSU5EVVNUUklFUzEfMB0GA1UECxMWQ1JZUFRPR1JBUEhJ
QyBTRVJWSUNFUzEeMBwGA1UEAxMVV05UUk1VVEUgVEVTVCBFQyBDQSAxMB4XDTI1
MTExOTIwNTgwMVoXDTQ1MTExNDIxNTgwMVowgYgxCzAJBgNVBAYTAlVTMQkwBwYD
VQQIEwAxCTAHBgNVBAcTADEiMCAGA1UEChMZV05UUk1VVEUgSEVBVlkgSU5EVVNU
UklFUzEfMB0GA1UECxMWQ1JZUFRPR1JBUEhJQyBTRVJWSUNFUzEeMBwGA1UEAxMV
V05UUk1VVEUgVEVTVCBFQyBDQSAxMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQA
QxmTxzo1XOK0HDrtn92bexC4sXr8GnU+oATiXied3e1AWVOux9XtaWduY+a+r6Kb
1rxMVyebn9KqtwNw+9KSXaEB1IN9QzfdxEcJgRIAVtFplOqCip5xKK0B+woo3wXm
3ndq2kJts86aONqQ0m2gRrsmAKAX4pwmMnAHFF7veBcpsqujRTBDMA4GA1UdDwEB
/wQEAwICBDASBgNVHRMBAf8ECDAGAQH/AgEDMB0GA1UdDgQWBBSNqRkvwUgIHGa2
jKmA2Q3w6Ju/FzAKBggqhkjOPQQDBAOBjAAwgYgCQgCckIFCjzJExxbV9dqm92nr
safC3kqhCxjmilf0IYWVj5f1kymoFr3jPpmy0iFcteUk0QTcqpnUT4i140lxtyK8
NAJCAVxbicZgVns9rgp6hu14l81j0XMpNgzy0QxscjMpWS/17iDJ4Y5vCWpwekrr
F1cmmRpsodONacAvTml4ehKE2ekx
-----END CERTIFICATE-----

8
certlib/testdata/ec-ca-priv.pem vendored Normal file
View File

@@ -0,0 +1,8 @@
-----BEGIN PRIVATE KEY-----
MIHuAgEAMBAGByqGSM49AgEGBSuBBAAjBIHWMIHTAgEBBEIAzkf/rvLGJBTVHHHr
lUhzsRJZgkyzSY5YE3KBReDyFWc+OB48C1gdYB1u7+PxgyfwYACjPx2y1AxN8fJh
XonY39mhgYkDgYYABABDGZPHOjVc4rQcOu2f3Zt7ELixevwadT6gBOJeJ53d7UBZ
U67H1e1pZ25j5r6vopvWvExXJ5uf0qq3A3D70pJdoQHUg31DN93ERwmBEgBW0WmU
6oKKnnEorQH7CijfBebed2raQm2zzpo42pDSbaBGuyYAoBfinCYycAcUXu94Fymy
qw==
-----END PRIVATE KEY-----

13
certlib/testdata/ec-ca.yaml vendored Normal file
View File

@@ -0,0 +1,13 @@
key:
algorithm: ecdsa
size: 521
subject:
common_name: WNTRMUTE TEST EC CA 1
country: US
organization: WNTRMUTE HEAVY INDUSTRIES
organizational_unit: CRYPTOGRAPHIC SERVICES
profile:
is_ca: true
path_len: 3
key_uses: cert sign
expiry: 20y

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-----

8
certlib/testdata/rsa-ca-cert.csr vendored Normal file
View File

@@ -0,0 +1,8 @@
-----BEGIN CERTIFICATE REQUEST-----
MIIBCjCBvQIBADCBiTELMAkGA1UEBhMCVVMxCTAHBgNVBAgTADEJMAcGA1UEBxMA
MSIwIAYDVQQKExlXTlRSTVVURSBIRUFWWSBJTkRVU1RSSUVTMR8wHQYDVQQLExZD
UllQVE9HUkFQSElDIFNFUlZJQ0VTMR8wHQYDVQQDExZXTlRSTVVURSBURVNUIFJT
QSBDQSAxMCowBQYDK2VwAyEA1Lai2WChuUH2kq4LWddp6TlcmpuuBz6G43e9efsZ
GBqgADAFBgMrZXADQQDbBl1gW07c0g9UQmK2g8QkVIXzr2TLrOjXVAptlcW/3rPO
M3iQM2mGwZWMwv7t6C4C7xBaLcUkcqT3b4S+MaUK
-----END CERTIFICATE REQUEST-----

14
certlib/testdata/rsa-ca-cert.pem vendored Normal file
View File

@@ -0,0 +1,14 @@
-----BEGIN CERTIFICATE-----
MIICHDCCAc6gAwIBAgIVAN1AKHhLNsqcBEKYCqgjEMG65hhvMAUGAytlcDCBiTEL
MAkGA1UEBhMCVVMxCTAHBgNVBAgTADEJMAcGA1UEBxMAMSIwIAYDVQQKExlXTlRS
TVVURSBIRUFWWSBJTkRVU1RSSUVTMR8wHQYDVQQLExZDUllQVE9HUkFQSElDIFNF
UlZJQ0VTMR8wHQYDVQQDExZXTlRSTVVURSBURVNUIFJTQSBDQSAxMB4XDTI1MTEx
OTIxMDQyNVoXDTQ1MTExNDIyMDQyNVowgYkxCzAJBgNVBAYTAlVTMQkwBwYDVQQI
EwAxCTAHBgNVBAcTADEiMCAGA1UEChMZV05UUk1VVEUgSEVBVlkgSU5EVVNUUklF
UzEfMB0GA1UECxMWQ1JZUFRPR1JBUEhJQyBTRVJWSUNFUzEfMB0GA1UEAxMWV05U
Uk1VVEUgVEVTVCBSU0EgQ0EgMTAqMAUGAytlcAMhANS2otlgoblB9pKuC1nXaek5
XJqbrgc+huN3vXn7GRgao0UwQzAOBgNVHQ8BAf8EBAMCAgQwEgYDVR0TAQH/BAgw
BgEB/wIBAzAdBgNVHQ4EFgQUetUgY5rlFq+OCeYe0Eqmp8Ek488wBQYDK2VwA0EA
LIFZo6FQL+8q8h66Bm7favIh2AlqsXA45DpRUN2LpjNm/7NbTPDw52y8cLegUUMc
UhDyk20fGg5g6cLywC0mDA==
-----END CERTIFICATE-----

3
certlib/testdata/rsa-ca-priv.pem vendored Normal file
View File

@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIDDkYbIZKArACSevxtX2Rr8MQSeJ4Jz0qJEe/YgHfjzo
-----END PRIVATE KEY-----

13
certlib/testdata/rsa-ca.yaml vendored Normal file
View File

@@ -0,0 +1,13 @@
key:
algorithm: ed25519
size: 4096
subject:
common_name: WNTRMUTE TEST RSA CA 1
country: US
organization: WNTRMUTE HEAVY INDUSTRIES
organizational_unit: CRYPTOGRAPHIC SERVICES
profile:
is_ca: true
path_len: 3
key_uses: cert sign
expiry: 20y

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

@@ -12,6 +12,7 @@ chains:
include_single: true
include_individual: true
manifest: true
encoding: pemcrt
formats:
- zip
- tgz

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,375 +2,45 @@
package main
import (
"crypto/dsa"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"crypto/tls"
"flag"
"fmt"
"os"
"sort"
"strings"
"github.com/kr/text"
"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"
)
// 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 {
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)
}
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)
}
}
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()
opts := &certlib.FetcherOpts{
SkipVerify: true,
Roots: nil,
}
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")
certs, err := certlib.GetCertificateChain(filename, opts)
certs, err := fetch.GetCertificateChain(filename, tlsCfg)
if err != nil {
_, _ = lib.Warn(err, "couldn't read certificate")
lib.Warn(err, "couldn't read certificate")
continue
}
if leafOnly {
displayCert(certs[0])
if config.leafOnly {
dump.DisplayCert(os.Stdout, certs[0], config.showHash)
continue
}
for i := range certs {
displayCert(certs[i])
dump.DisplayCert(os.Stdout, certs[i], config.showHash)
}
}
}

View File

@@ -2,95 +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() {
opts := &certlib.FetcherOpts{}
var (
skipVerify bool
strictTLS bool
leeway = verify.DefaultLeeway
warnOnly bool
)
flag.BoolVar(&opts.SkipVerify, "k", false, "skip server verification")
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()
tlsCfg, err := dialer.BaselineTLSConfig(skipVerify, strictTLS)
die.If(err)
for _, file := range flag.Args() {
certs, err := certlib.GetCertificateChain(file, opts)
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,16 +33,23 @@ func serialString(cert *x509.Certificate, mode lib.HexEncodeMode) string {
}
func main() {
opts := &certlib.FetcherOpts{}
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(&opts.SkipVerify, "k", false, "skip server verification")
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.GetCertificate(arg, opts)
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, skipVerify, verbose bool
strictTLS bool
}
func parseFlags() appConfig {
@@ -43,107 +29,66 @@ func parseFlags() appConfig {
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())
}
combinedPool, err := certlib.LoadFullCertPool(cfg.caFile, cfg.intFile)
if err != nil {
return fmt.Errorf("failed to build combined pool: %w", err)
}
opts := &certlib.FetcherOpts{
Roots: combinedPool,
SkipVerify: cfg.skipVerify,
}
chain, err := certlib.GetCertificateChain(flag.Arg(0), opts)
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,29 +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"
"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
@@ -42,117 +32,6 @@ 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, mode lib.HexEncodeMode) string {
return lib.HexEncode(in, mode)
}
type subjectPublicKeyInfo struct {
Algorithm pkix.AlgorithmIdentifier
SubjectPublicKey asn1.BitString
}
func main() {
var help, shouldMatch bool
var displayModeString string
@@ -168,27 +47,22 @@ func main() {
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[:], displayMode)
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()
@@ -68,7 +65,7 @@ func printPeerCertificates(certificates []*x509.Certificate) {
fmt.Printf("\tSubject: %s\n", cert.Subject)
fmt.Printf("\tIssuer: %s\n", cert.Issuer)
fmt.Printf("\tDNS Names: %v\n", cert.DNSNames)
fmt.Printf("\tNot Before: %s\n:", cert.NotBefore)
fmt.Printf("\tNot Before: %s\n", cert.NotBefore)
fmt.Printf("\tNot After: %s\n", cert.NotAfter)
}
}

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())
}

3
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
)

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

View File

@@ -1,25 +1,23 @@
package certlib
package fetch
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"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"
)
// FetcherOpts are options for fetching certificates. They are only applicable to ServerFetcher.
type FetcherOpts struct {
SkipVerify bool
Roots *x509.CertPool
}
// 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.
@@ -69,25 +67,16 @@ func (sf *ServerFetcher) String() string {
}
func (sf *ServerFetcher) GetChain() ([]*x509.Certificate, error) {
config := &tls.Config{
InsecureSkipVerify: sf.insecure, // #nosec G402 - no shit sherlock
RootCAs: sf.roots,
opts := dialer.Opts{
TLSConfig: &tls.Config{
InsecureSkipVerify: sf.insecure, // #nosec G402 - no shit sherlock
RootCAs: sf.roots,
},
}
dialer := &tls.Dialer{
Config: config,
}
hostSpec := net.JoinHostPort(sf.host, lib.Itoa(sf.port, -1))
netConn, err := dialer.DialContext(context.Background(), "tcp", hostSpec)
conn, err := dialer.DialTLS(context.Background(), net.JoinHostPort(sf.host, lib.Itoa(sf.port, -1)), opts)
if err != nil {
return nil, fmt.Errorf("dialing server: %w", err)
}
conn, ok := netConn.(*tls.Conn)
if !ok {
return nil, errors.New("connection is not TLS")
return nil, fmt.Errorf("failed to dial server: %w", err)
}
defer conn.Close()
@@ -125,10 +114,10 @@ func (ff *FileFetcher) GetChain() ([]*x509.Certificate, error) {
return nil, fmt.Errorf("failed to read from stdin: %w", err)
}
return ParseCertificatesPEM(certData)
return certlib.ParseCertificatesPEM(certData)
}
certs, err := LoadCertificates(ff.path)
certs, err := certlib.LoadCertificates(ff.path)
if err != nil {
return nil, fmt.Errorf("failed to load chain: %w", err)
}
@@ -146,7 +135,10 @@ func (ff *FileFetcher) Get() (*x509.Certificate, error) {
}
// GetCertificateChain fetches a certificate chain from a remote source.
func GetCertificateChain(spec string, opts *FetcherOpts) ([]*x509.Certificate, error) {
// 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()
}
@@ -156,17 +148,17 @@ func GetCertificateChain(spec string, opts *FetcherOpts) ([]*x509.Certificate, e
return nil, err
}
if opts != nil {
fetcher.insecure = opts.SkipVerify
fetcher.roots = opts.Roots
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, opts *FetcherOpts) (*x509.Certificate, error) {
certs, err := GetCertificateChain(spec, opts)
func GetCertificate(spec string, cfg *tls.Config) (*x509.Certificate, error) {
certs, err := GetCertificateChain(spec, cfg)
if err != nil {
return nil, err
}

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 (
@@ -127,6 +211,8 @@ const (
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 {
@@ -141,6 +227,8 @@ func (m HexEncodeMode) String() string {
return "ucolon"
case HexEncodeBytes:
return "bytes"
case HexEncodeBase64:
return "base64"
default:
panic("invalid hex encode mode")
}
@@ -158,6 +246,8 @@ func ParseHexEncodeMode(s string) HexEncodeMode {
return HexEncodeUpperColon
case "bytes":
return HexEncodeBytes
case "base64":
return HexEncodeBase64
}
panic("invalid hex encode mode")
@@ -219,21 +309,22 @@ func bytesAsByteSliceString(buf []byte) string {
return sb.String()
}
// HexEncode encodes the given bytes as a hexadecimal 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 {
str := hexEncode(b)
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."