Compare commits

..

9 Commits

10 changed files with 328 additions and 20 deletions

View File

@@ -488,6 +488,8 @@ linters:
linters: [ exhaustive, mnd, revive ] linters: [ exhaustive, mnd, revive ]
- path: 'backoff/backoff_test.go' - path: 'backoff/backoff_test.go'
linters: [ testpackage ] linters: [ testpackage ]
- path: "cmd/kgz/main.go"
linters: [ unconvert ]
- path: 'dbg/dbg_test.go' - path: 'dbg/dbg_test.go'
linters: [ testpackage ] linters: [ testpackage ]
- path: 'log/logger.go' - path: 'log/logger.go'

View File

@@ -1,5 +1,28 @@
CHANGELOG CHANGELOG
v1.19.0 - 2026-02-12
Added:
- certlib/dump: DisplayCSR function for dumping Certificate Signing Request information.
- certlib: MatchKeysCSR function for testing if a CSR's public key matches a private key.
v1.18.0 - 2025-11-21
Changed:
- disable unconvert for kgz, as various platforms complain about it.
v1.17.2 - 2025-11-21
Note: 1.17.2 was a mangled release.
Changed:
- certlib: fix request configs in testdata.
v1.17.1 - 2025-11-21
Changed:
- certlib: various code cleanups.
v1.17.0 - 2025-11-21 v1.17.0 - 2025-11-21
Added: Added:

View File

@@ -448,13 +448,13 @@ func encodeCertsToFiles(
derContent = append(derContent, cert.Raw...) derContent = append(derContent, cert.Raw...)
} }
files = append(files, fileEntry{ files = append(files, fileEntry{
name: baseName + ".crt", name: baseName + ".cer",
content: derContent, content: derContent,
}) })
} else if len(certs) > 0 { } else if len(certs) > 0 {
// Individual DER file (should only have one cert) // Individual DER file (should only have one cert)
files = append(files, fileEntry{ files = append(files, fileEntry{
name: baseName + ".crt", name: baseName + ".cer",
content: certs[0].Raw, content: certs[0].Raw,
}) })
} }
@@ -472,17 +472,17 @@ func encodeCertsToFiles(
derContent = append(derContent, cert.Raw...) derContent = append(derContent, cert.Raw...)
} }
files = append(files, fileEntry{ files = append(files, fileEntry{
name: baseName + ".crt", name: baseName + ".cer",
content: derContent, content: derContent,
}) })
} else if len(certs) > 0 { } else if len(certs) > 0 {
files = append(files, fileEntry{ files = append(files, fileEntry{
name: baseName + ".crt", name: baseName + ".cer",
content: certs[0].Raw, content: certs[0].Raw,
}) })
} }
default: default:
return nil, fmt.Errorf("unsupported encoding: %s (must be 'pem', 'der', or 'both')", encoding) return nil, fmt.Errorf("unsupported encoding: %s (must be 'pem', 'der', 'both', 'crt', 'pemcrt')", encoding)
} }
return files, nil return files, nil

View File

@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"math/big" "math/big"
"net" "net"
"slices"
"strings" "strings"
"time" "time"
@@ -60,7 +61,7 @@ type Subject struct {
Province string `yaml:"province"` Province string `yaml:"province"`
Organization string `yaml:"organization"` Organization string `yaml:"organization"`
OrganizationalUnit string `yaml:"organizational_unit"` OrganizationalUnit string `yaml:"organizational_unit"`
Email string `yaml:"email"` Email []string `yaml:"email"`
DNSNames []string `yaml:"dns"` DNSNames []string `yaml:"dns"`
IPAddresses []string `yaml:"ips"` IPAddresses []string `yaml:"ips"`
} }
@@ -88,18 +89,20 @@ func (cs CertificateRequest) Request(priv crypto.PrivateKey) (*x509.CertificateR
} }
} }
dnsNames := cs.Subject.DNSNames
if isFQDN(cs.Subject.CommonName) && !slices.Contains(dnsNames, cs.Subject.CommonName) {
dnsNames = append(dnsNames, cs.Subject.CommonName)
}
req := &x509.CertificateRequest{ req := &x509.CertificateRequest{
PublicKeyAlgorithm: 0, PublicKeyAlgorithm: 0,
PublicKey: getPublic(priv), PublicKey: getPublic(priv),
Subject: subject, Subject: subject,
DNSNames: cs.Subject.DNSNames, EmailAddresses: cs.Subject.Email,
DNSNames: dnsNames,
IPAddresses: ipAddresses, IPAddresses: ipAddresses,
} }
if cs.Subject.Email != "" {
req.EmailAddresses = []string{cs.Subject.Email}
}
reqBytes, err := x509.CreateCertificateRequest(rand.Reader, req, priv) reqBytes, err := x509.CreateCertificateRequest(rand.Reader, req, priv)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create certificate request: %w", err) return nil, fmt.Errorf("failed to create certificate request: %w", err)
@@ -130,7 +133,7 @@ func (cs CertificateRequest) Generate() (crypto.PrivateKey, *x509.CertificateReq
type Profile struct { type Profile struct {
IsCA bool `yaml:"is_ca"` IsCA bool `yaml:"is_ca"`
PathLen int `yaml:"path_len"` PathLen int `yaml:"path_len"`
KeyUse string `yaml:"key_uses"` KeyUse []string `yaml:"key_uses"`
ExtKeyUsages []string `yaml:"ext_key_usages"` ExtKeyUsages []string `yaml:"ext_key_usages"`
Expiry string `yaml:"expiry"` Expiry string `yaml:"expiry"`
} }
@@ -161,15 +164,17 @@ func (p Profile) templateFromRequest(req *x509.CertificateRequest) (*x509.Certif
IPAddresses: req.IPAddresses, IPAddresses: req.IPAddresses,
} }
var ok bool for _, sku := range p.KeyUse {
certTemplate.KeyUsage, ok = keyUsageStrings[p.KeyUse] ku, ok := keyUsageStrings[sku]
if !ok { if !ok {
return nil, fmt.Errorf("invalid key usage: %s", p.KeyUse) return nil, fmt.Errorf("invalid key usage: %s", p.KeyUse)
}
certTemplate.KeyUsage |= ku
} }
var eku x509.ExtKeyUsage
for _, extKeyUsage := range p.ExtKeyUsages { for _, extKeyUsage := range p.ExtKeyUsages {
eku, ok = extKeyUsageStrings[extKeyUsage] eku, ok := extKeyUsageStrings[extKeyUsage]
if !ok { if !ok {
return nil, fmt.Errorf("invalid extended key usage: %s", extKeyUsage) return nil, fmt.Errorf("invalid extended key usage: %s", extKeyUsage)
} }
@@ -211,6 +216,32 @@ func (p Profile) SelfSign(req *x509.CertificateRequest, priv crypto.PrivateKey)
return p.SignRequest(certTemplate, req, priv) return p.SignRequest(certTemplate, req, priv)
} }
// isFQDN returns true if s looks like a fully-qualified domain name.
func isFQDN(s string) bool {
if s == "" {
return false
}
// Must contain at least one dot and no spaces.
if !strings.Contains(s, ".") || strings.ContainsAny(s, " \t") {
return false
}
// Each label must be non-empty and consist of letters, digits, or hyphens.
for label := range strings.SplitSeq(strings.TrimSuffix(s, "."), ".") {
if label == "" {
return false
}
for _, c := range label {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') {
return false
}
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
}
return true
}
func SerialNumber() (*big.Int, error) { func SerialNumber() (*big.Int, error) {
serialNumberBytes := make([]byte, 20) serialNumberBytes := make([]byte, 20)
_, err := rand.Read(serialNumberBytes) _, err := rand.Read(serialNumberBytes)

View File

@@ -0,0 +1,104 @@
package certgen
import (
"slices"
"testing"
)
func TestIsFQDN(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"example.com", true},
{"sub.example.com", true},
{"example.com.", true}, // trailing dot
{"localhost", false}, // no dot
{"", false},
{"foo bar.com", false}, // space
{"-bad.com", false}, // leading hyphen
{"bad-.com", false}, // trailing hyphen
{"a..b.com", false}, // empty label
}
for _, tt := range tests {
got := isFQDN(tt.input)
if got != tt.want {
t.Errorf("isFQDN(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
func TestRequestAddsFQDNAsDNSSAN(t *testing.T) {
creq := &CertificateRequest{
KeySpec: KeySpec{Algorithm: "ecdsa", Size: 256},
Subject: Subject{
CommonName: "example.com",
Organization: "Test Org",
},
Profile: Profile{
Expiry: "1h",
},
}
_, req, err := creq.Generate()
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
if !slices.Contains(req.DNSNames, "example.com") {
t.Errorf("expected DNS SAN to contain %q, got %v", "example.com", req.DNSNames)
}
}
func TestRequestFQDNNotDuplicated(t *testing.T) {
creq := &CertificateRequest{
KeySpec: KeySpec{Algorithm: "ecdsa", Size: 256},
Subject: Subject{
CommonName: "example.com",
Organization: "Test Org",
DNSNames: []string{"example.com", "www.example.com"},
},
Profile: Profile{
Expiry: "1h",
},
}
_, req, err := creq.Generate()
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
count := 0
for _, name := range req.DNSNames {
if name == "example.com" {
count++
}
}
if count != 1 {
t.Errorf("expected exactly 1 occurrence of %q in DNS SANs, got %d: %v", "example.com", count, req.DNSNames)
}
}
func TestRequestNonFQDNCommonNameNotAdded(t *testing.T) {
creq := &CertificateRequest{
KeySpec: KeySpec{Algorithm: "ecdsa", Size: 256},
Subject: Subject{
CommonName: "localhost",
Organization: "Test Org",
},
Profile: Profile{
Expiry: "1h",
},
}
_, req, err := creq.Generate()
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
if slices.Contains(req.DNSNames, "localhost") {
t.Errorf("expected DNS SANs to not contain %q, got %v", "localhost", req.DNSNames)
}
}

View File

@@ -165,6 +165,28 @@ func certPublic(cert *x509.Certificate) string {
} }
} }
func csrPublic(csr *x509.CertificateRequest) string {
switch pub := csr.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 { func DisplayName(name pkix.Name) string {
var ns []string var ns []string
@@ -333,3 +355,36 @@ func DisplayCert(w io.Writer, cert *x509.Certificate, showHash bool) {
} }
} }
} }
func DisplayCSR(w io.Writer, csr *x509.CertificateRequest, showHash bool) {
fmt.Fprintln(w, "CERTIFICATE REQUEST")
if showHash {
fmt.Fprintln(w, wrap(fmt.Sprintf("SHA256: %x", sha256.Sum256(csr.Raw)), 0))
}
fmt.Fprintln(w, wrap("Subject: "+DisplayName(csr.Subject), 0))
fmt.Fprintf(w, "\tSignature algorithm: %s / %s\n", sigAlgoPK(csr.SignatureAlgorithm),
sigAlgoHash(csr.SignatureAlgorithm))
fmt.Fprintln(w, "Details:")
wrapPrint("Public key: "+csrPublic(csr), 1)
validNames := make([]string, 0, len(csr.DNSNames)+len(csr.EmailAddresses)+len(csr.IPAddresses)+len(csr.URIs))
for i := range csr.DNSNames {
validNames = append(validNames, "dns:"+csr.DNSNames[i])
}
for i := range csr.EmailAddresses {
validNames = append(validNames, "email:"+csr.EmailAddresses[i])
}
for i := range csr.IPAddresses {
validNames = append(validNames, "ip:"+csr.IPAddresses[i].String())
}
for i := range csr.URIs {
validNames = append(validNames, "uri:"+csr.URIs[i].String())
}
sans := fmt.Sprintf("SANs (%d): %s\n", len(validNames), strings.Join(validNames, ", "))
wrapPrint(sans, 1)
}

View File

@@ -133,3 +133,48 @@ func MatchKeys(cert *x509.Certificate, priv crypto.Signer) (bool, string) {
return false, fmt.Sprintf("unrecognised private key type: %T", priv.Public()) return false, fmt.Sprintf("unrecognised private key type: %T", priv.Public())
} }
} }
// MatchKeysCSR determines whether the CSR's public key matches the given private key.
// It returns true if they match; otherwise, it returns false and a human-friendly reason.
func MatchKeysCSR(csr *x509.CertificateRequest, priv crypto.Signer) (bool, string) {
switch keyPub := priv.Public().(type) {
case *rsa.PublicKey:
switch csrPub := csr.PublicKey.(type) {
case *rsa.PublicKey:
if matchRSA(csrPub, 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 CSR public key type: %T", csr.PublicKey)
}
case *ecdsa.PublicKey:
switch csrPub := csr.PublicKey.(type) {
case *ecdsa.PublicKey:
if matchECDSA(csrPub, keyPub) {
return true, ""
}
// Determine a more precise reason
kc := getECCurve(keyPub)
cc := getECCurve(csrPub)
if kc == curveInvalid {
return false, "invalid private key curve"
}
if cc == curveRSA {
return false, "private key is EC, CSR 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, CSR is RSA"
default:
return false, fmt.Sprintf("unsupported CSR public key type: %T", csr.PublicKey)
}
default:
return false, fmt.Sprintf("unrecognised private key type: %T", priv.Public())
}
}

View File

@@ -9,5 +9,6 @@ subject:
profile: profile:
is_ca: true is_ca: true
path_len: 3 path_len: 3
key_uses: cert sign key_uses:
- cert sign
expiry: 20y expiry: 20y

View File

@@ -9,5 +9,6 @@ subject:
profile: profile:
is_ca: true is_ca: true
path_len: 3 path_len: 3
key_uses: cert sign key_uses:
- cert sign
expiry: 20y expiry: 20y

46
cmd/ccfind/main.go Normal file
View File

@@ -0,0 +1,46 @@
package main
// Prompt:
// The current main.go should accept a list of paths to search. In each
// of those paths, without recursing, it should find all files ending in
// C/C++ source extensions and print them one per line.
import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
)
var extensions = []string{
".c", ".cpp", ".cc", ".cxx",
".h", ".hpp", ".hh", ".hxx",
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "Usage: %s <path> [path...]\n", os.Args[0])
os.Exit(1)
}
for _, path := range os.Args[1:] {
entries, err := os.ReadDir(path)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading %s: %v\n", path, err)
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
ext := filepath.Ext(name)
if slices.Contains(extensions, strings.ToLower(ext)) {
fmt.Println(filepath.Join(path, name))
}
}
}
}