Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,6 @@ jobs:
- name: Toolchain version
run: go version

- name: Check gofmt
run: |
fmtout=$(gofmt -l .)
if [ -n "$fmtout" ]; then
echo "These files are not gofmt-clean:"
echo "$fmtout"
exit 1
fi

# The Makefile defaults to gotip for local development; CI has a
# real 1.27 toolchain on PATH as `go`.
- name: Vet
Expand All @@ -64,3 +55,26 @@ jobs:
# workflow's job.
- name: Vet stress-tagged tests
run: go vet -tags=stress ./integration/...

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Install Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}

# golangci-lint release binaries are built with the latest released
# Go and cannot type-check a `go 1.27` module, so build it from
# source with the toolchain installed above instead of using
# golangci/golangci-lint-action. setup-go's cache keeps this fast
# after the first run. Keep the pinned version in sync with the
# Makefile comment. Formatting (gofmt + goimports) is checked here
# too, via the formatters enabled in .golangci.yml.
- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2

- name: Lint
run: make GO=go lint
85 changes: 85 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
version: "2"

run:
# The stress tag only adds integration/stress_test.go; linting with it
# on covers the whole tree in one pass.
build-tags:
- stress

linters:
# Standard set: errcheck, govet, ineffassign, staticcheck, unused.
default: standard
enable:
- gosec
- misspell
- unconvert
# golangci-lint vendors honnef.co/go/tools v0.7.0 (staticcheck
# 2026.1), whose IR builder panics on the Go 1.27 standard library.
# Re-enable staticcheck and unused once golangci-lint ships
# staticcheck 2026.2+.
disable:
- staticcheck
- unused
settings:
errcheck:
exclude-functions:
# Best-effort HTTP response writes; there is nothing useful to
# do with a client that hung up.
- (net/http.ResponseWriter).Write
# Close on a read-only body; errors there are meaningless.
- (io.ReadCloser).Close
# Fprint* is used on HTTP responses and hash.Hash writers, where
# the error is likewise not actionable.
- fmt.Fprint
- fmt.Fprintf
- fmt.Fprintln
gosec:
excludes:
# G104 (unhandled errors) duplicates errcheck, which is
# configured precisely above.
- G104
# G115 (integer overflow on conversion): tlog tile/index math
# converts between uint64, int64, and int pervasively and
# intentionally; the flagged sites are all bounded by tree size.
- G115
# G304 (file path from variable): key seeds, configs, and the
# storage root are operator-supplied paths by design.
- G304
config:
# Log data (tiles, checkpoints) is public and served over HTTP;
# world-readable directories and files are intentional.
G301: "0755"
G302: "0644"
exclusions:
rules:
# Tests build/run local binaries, hit local test servers with
# constructed URLs, and write world-readable fixtures; gosec's
# G107/G204/G306/G602 hits there are all noise.
- path: _test\.go
linters:
- gosec
# cactus-cli's purpose is fetching operator-supplied log URLs and
# reading operator-supplied cert files, so G703 (path traversal)
# and G704 (SSRF) flag the tool working as intended.
- path: cmd/cactus-cli/
text: "G70[34]"
# The tile handler serves stored binary tiles as
# application/octet-stream; G705's XSS taint trace (URL path ->
# storage key -> response body) is not script injection.
- path: tile/server\.go
text: "G705"

issues:
# Report everything; the default per-linter and per-issue caps hide
# findings.
max-issues-per-linter: 0
max-same-issues: 0

formatters:
enable:
- gofmt
- goimports
settings:
goimports:
local-prefixes:
- github.com/letsencrypt/cactus
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ make build # builds bin/cactus, bin/cactus-cli, bin/cactus-keygen,
make test # gotip test ./...
make test-race # gotip test -race ./...
make vet # gotip vet ./...
make lint # golangci-lint run (config: .golangci.yml)
make fmt # golangci-lint fmt (gofmt + goimports)
make integration # gotip test -race -count=1 -tags=integration ./integration/...
make stress # bulk issuance stress test (800 certs, `stress` build tag)

Expand Down
16 changes: 15 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build test test-race vet integration stress clean \
.PHONY: build test test-race vet lint fmt integration stress clean \
docker-binaries docker-build docker-up docker-down docker-logs

# cactus requires Go 1.27+ (built-in crypto/mldsa). Until 1.27 ships, the
Expand All @@ -22,6 +22,20 @@ test-race:
vet:
$(GO) vet ./...

# golangci-lint must itself be built with a Go 1.27+ toolchain to
# type-check this module, and its release binaries are built with the
# latest released Go, so install it from source:
# gotip install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
# The PATH prefix makes golangci-lint's `go` invocations use $(GO)'s
# toolchain.
GOLANGCI_LINT ?= golangci-lint

lint:
PATH="$$($(GO) env GOROOT)/bin:$$PATH" GOTOOLCHAIN=local $(GOLANGCI_LINT) run

fmt:
PATH="$$($(GO) env GOROOT)/bin:$$PATH" GOTOOLCHAIN=local $(GOLANGCI_LINT) fmt

integration:
$(GO) test -race -count=1 -tags=integration ./integration/...

Expand Down
4 changes: 3 additions & 1 deletion acme/concurrent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ func TestConcurrentFinalizeOnSameOrder(t *testing.T) {
mustMarshal(NewOrderReq{Identifiers: []Identifier{{Type: "dns", Value: "race.test"}}}))
resp, body := post(t, base, "/new-order", jws)
var ord OrderResp
json.Unmarshal(body, &ord)
if err := json.Unmarshal(body, &ord); err != nil {
t.Fatalf("unmarshal order: %v", err)
}

csrKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
csrTmpl := &x509.CertificateRequest{
Expand Down
8 changes: 6 additions & 2 deletions acme/enhancement_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ func TestEnhancementURLPending(t *testing.T) {
mustMarshal(NewOrderReq{Identifiers: []Identifier{{Type: "dns", Value: "enh.test"}}}))
resp, body := post(t, hsrv.URL, "/new-order", jws)
var ord OrderResp
json.Unmarshal(body, &ord)
if err := json.Unmarshal(body, &ord); err != nil {
t.Fatalf("unmarshal order: %v", err)
}
csrKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
csr, _ := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
Subject: pkix.Name{CommonName: "enh.test"}, DNSNames: []string{"enh.test"},
Expand All @@ -90,7 +92,9 @@ func TestEnhancementURLPending(t *testing.T) {
mustMarshal(FinalizeReq{CSR: base64.RawURLEncoding.EncodeToString(csr)}))
resp, body = post(t, hsrv.URL, strings.TrimPrefix(ord.Finalize, hsrv.URL), jws)
var ord2 OrderResp
json.Unmarshal(body, &ord2)
if err := json.Unmarshal(body, &ord2); err != nil {
t.Fatalf("unmarshal order: %v", err)
}
if ord2.Certificate == "" {
t.Fatal("missing cert URL")
}
Expand Down
6 changes: 3 additions & 3 deletions acme/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -1081,7 +1081,7 @@ func (s *Server) handleCert(w http.ResponseWriter, r *http.Request) {
// If neither is configured, fall back to plain PEM rather than
// emitting an empty list (which BuildPropertyList rejects).
if len(taID) == 0 {
pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: der})
_ = pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: der})
return
}
pl, err := cert.BuildPropertyList(props)
Expand All @@ -1093,7 +1093,7 @@ func (s *Server) handleCert(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "application/pem-certificate-chain")
pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: der})
_ = pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: der})
}

// certIndex extracts the issuance-log index from a standalone cert's
Expand Down Expand Up @@ -1243,7 +1243,7 @@ func (s *Server) handleCertLandmarkRelative(w http.ResponseWriter, r *http.Reque
return
}
w.Header().Set("Content-Type", "application/pem-certificate-chain")
pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: der})
_ = pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: der})
}

// serveEnhancementPending is the §9.1 "not yet available" response for a
Expand Down
16 changes: 12 additions & 4 deletions acme/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ func TestDirectoryEndpoint(t *testing.T) {
}
defer resp.Body.Close()
var d Directory
json.NewDecoder(resp.Body).Decode(&d)
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
t.Fatalf("decode directory: %v", err)
}
if d.NewAccount == "" || d.NewOrder == "" || d.NewNonce == "" {
t.Errorf("directory missing fields: %+v", d)
}
Expand Down Expand Up @@ -213,7 +215,9 @@ func TestEndToEndIssuance(t *testing.T) {
t.Fatalf("new-order status=%d body=%s", resp.StatusCode, body)
}
var ord OrderResp
json.Unmarshal(body, &ord)
if err := json.Unmarshal(body, &ord); err != nil {
t.Fatalf("unmarshal order: %v", err)
}
orderURL := resp.Header.Get("Location")
if ord.Status != "ready" {
t.Errorf("order status = %q, want ready (auto-pass)", ord.Status)
Expand Down Expand Up @@ -242,7 +246,9 @@ func TestEndToEndIssuance(t *testing.T) {
t.Fatalf("finalize status=%d body=%s", resp.StatusCode, body)
}
var ord2 OrderResp
json.Unmarshal(body, &ord2)
if err := json.Unmarshal(body, &ord2); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if ord2.Status != "valid" {
t.Errorf("order status after finalize = %q", ord2.Status)
}
Expand Down Expand Up @@ -314,7 +320,9 @@ func newAccountAndOrder(t *testing.T, base, dnsName string) (*ecdsa.PrivateKey,
t.Fatalf("new-order: %d %s", resp.StatusCode, body)
}
var ord OrderResp
json.Unmarshal(body, &ord)
if err := json.Unmarshal(body, &ord); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
return acctKey, kid, ord, resp.Header.Get("Location")
}

Expand Down
25 changes: 18 additions & 7 deletions acme/http01_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ func TestHTTP01ChallengeMode(t *testing.T) {
mustMarshal(NewOrderReq{Identifiers: []Identifier{{Type: "dns", Value: chHost}}}))
resp, body := post(t, hsrv.URL, "/new-order", jws)
var ord OrderResp
json.Unmarshal(body, &ord)
if err := json.Unmarshal(body, &ord); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if ord.Status != "pending" {
t.Errorf("order should be pending in http-01 mode, got %q", ord.Status)
}
Expand All @@ -115,7 +117,9 @@ func TestHTTP01ChallengeMode(t *testing.T) {
jws = jwsSign(t, acctKey, nil, kid, nonce, ord.Authorizations[0], []byte("{}"))
resp, body = post(t, hsrv.URL, authzPath, jws)
var az AuthzResp
json.Unmarshal(body, &az)
if err := json.Unmarshal(body, &az); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if len(az.Challenges) == 0 {
t.Fatalf("no challenges; body=%s", body)
}
Expand All @@ -139,14 +143,15 @@ func TestHTTP01ChallengeMode(t *testing.T) {
t.Fatalf("challenge POST status=%d body=%s", resp.StatusCode, body)
}
var got ChallengeMsg
json.Unmarshal(body, &got)
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if got.Status != "valid" {
t.Errorf("challenge status = %q, want valid", got.Status)
}

// The order should now be ready.
nonce = resp.Header.Get("Replay-Nonce")
jws = jwsSign(t, acctKey, nil, kid, nonce, hsrv.URL+"/order/"+lastSeg(ord.Finalize), []byte("{}"))
orderURL := strings.TrimSuffix(ord.Finalize, "/finalize/"+lastSeg(ord.Finalize)) + "/order/" + lastSeg(ord.Finalize)
_ = orderURL
// Skip the GET-as-POST order check; instead just finalize and
Expand All @@ -166,7 +171,9 @@ func TestHTTP01ChallengeMode(t *testing.T) {
t.Fatalf("finalize status=%d body=%s", resp.StatusCode, body)
}
var ord2 OrderResp
json.Unmarshal(body, &ord2)
if err := json.Unmarshal(body, &ord2); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if ord2.Status != "valid" {
t.Errorf("post-finalize order status = %q, want valid", ord2.Status)
}
Expand Down Expand Up @@ -228,13 +235,17 @@ func TestHTTP01ChallengeRejectsBadResponse(t *testing.T) {
mustMarshal(NewOrderReq{Identifiers: []Identifier{{Type: "dns", Value: chHost}}}))
resp, body := post(t, hsrv.URL, "/new-order", jws)
var ord OrderResp
json.Unmarshal(body, &ord)
if err := json.Unmarshal(body, &ord); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
nonce = resp.Header.Get("Replay-Nonce")
authzURL := ord.Authorizations[0]
jws = jwsSign(t, acctKey, nil, kid, nonce, authzURL, []byte("{}"))
resp, body = post(t, hsrv.URL, strings.TrimPrefix(authzURL, hsrv.URL), jws)
var az AuthzResp
json.Unmarshal(body, &az)
if err := json.Unmarshal(body, &az); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if len(az.Challenges) == 0 {
t.Fatalf("no challenges; body=%s", body)
}
Expand Down
8 changes: 6 additions & 2 deletions acme/persist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ func finalizeOneCert(t *testing.T, base, dnsName string) (certURL, certID string
mustMarshal(NewOrderReq{Identifiers: []Identifier{{Type: "dns", Value: dnsName}}}))
resp, body := post(t, base, "/new-order", jws)
var ord OrderResp
json.Unmarshal(body, &ord)
if err := json.Unmarshal(body, &ord); err != nil {
t.Fatalf("unmarshal body: %v", err)
}

// CSR.
csrKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
Expand All @@ -140,7 +142,9 @@ func finalizeOneCert(t *testing.T, base, dnsName string) (certURL, certID string
mustMarshal(FinalizeReq{CSR: base64.RawURLEncoding.EncodeToString(csrDER)}))
resp, body = post(t, base, strings.TrimPrefix(ord.Finalize, base), jws)
var ord2 OrderResp
json.Unmarshal(body, &ord2)
if err := json.Unmarshal(body, &ord2); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
return ord2.Certificate, lastSegment(ord2.Certificate), acctKey, kid
}

Expand Down
5 changes: 3 additions & 2 deletions ca/issuer.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import (
"math/big"
"time"

"github.com/letsencrypt/cactus/cert"
cactuslog "github.com/letsencrypt/cactus/log"
"golang.org/x/crypto/cryptobyte"
cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1"

"github.com/letsencrypt/cactus/cert"
cactuslog "github.com/letsencrypt/cactus/log"
)

// LogAPI is the subset of *log.Log the issuer needs. Defined as an
Expand Down
3 changes: 2 additions & 1 deletion ca/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ import (
"strings"
"time"

"github.com/letsencrypt/cactus/cert"
"golang.org/x/crypto/cryptobyte"
cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1"

"github.com/letsencrypt/cactus/cert"
)

// OrderInput carries the relevant ACME order state. It is the
Expand Down
3 changes: 2 additions & 1 deletion cert/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import (
"hash"
"time"

"github.com/letsencrypt/cactus/tlogx"
"golang.org/x/crypto/cryptobyte"

"github.com/letsencrypt/cactus/tlogx"
)

// TBSCertificateLogEntry is the §5.2.1 ASN.1 SEQUENCE that the log
Expand Down
Loading