mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 19:58:52 +00:00
5dc698307b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit bc6039a (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
368 lines
13 KiB
Go
368 lines
13 KiB
Go
package local_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/asn1"
|
|
"encoding/pem"
|
|
"io"
|
|
"log/slog"
|
|
"math/big"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ocsp"
|
|
|
|
"github.com/certctl-io/certctl/internal/connector/issuer"
|
|
"github.com/certctl-io/certctl/internal/connector/issuer/local"
|
|
"github.com/certctl-io/certctl/internal/crypto/signer"
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
)
|
|
|
|
// fakeResponderRepo is an in-memory repository.OCSPResponderRepository
|
|
// for tests that exercise the responder bootstrap path without needing
|
|
// a real Postgres + testcontainers harness. The Postgres impl is
|
|
// covered by the testcontainers tests in
|
|
// internal/repository/postgres/ocsp_responder_test.go (CI only — needs
|
|
// Docker).
|
|
type fakeResponderRepo struct {
|
|
mu sync.Mutex
|
|
rows map[string]*domain.OCSPResponder
|
|
putCount int // bumped on every Put for assertion
|
|
getCount int
|
|
}
|
|
|
|
func newFakeResponderRepo() *fakeResponderRepo {
|
|
return &fakeResponderRepo{rows: map[string]*domain.OCSPResponder{}}
|
|
}
|
|
|
|
func (r *fakeResponderRepo) Get(ctx context.Context, issuerID string) (*domain.OCSPResponder, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.getCount++
|
|
if row, ok := r.rows[issuerID]; ok {
|
|
// Return a copy so callers can't mutate our state.
|
|
copy := *row
|
|
return ©, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *fakeResponderRepo) Put(ctx context.Context, responder *domain.OCSPResponder) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.putCount++
|
|
copy := *responder
|
|
r.rows[responder.IssuerID] = ©
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeResponderRepo) ListExpiring(ctx context.Context, grace time.Duration, now time.Time) ([]*domain.OCSPResponder, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
var out []*domain.OCSPResponder
|
|
threshold := now.Add(grace)
|
|
for _, row := range r.rows {
|
|
if !row.NotAfter.After(threshold) {
|
|
copy := *row
|
|
out = append(out, ©)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// helper: build a Connector wired for the responder bootstrap path.
|
|
func newConnectorWithResponderDeps(t *testing.T) (*local.Connector, *fakeResponderRepo) {
|
|
t.Helper()
|
|
|
|
conn := local.New(&local.Config{
|
|
CACommonName: "Test Local CA",
|
|
ValidityDays: 30,
|
|
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
|
|
repo := newFakeResponderRepo()
|
|
driver := signer.NewMemoryDriver()
|
|
|
|
conn.SetOCSPResponderRepo(repo)
|
|
conn.SetSignerDriver(driver)
|
|
conn.SetIssuerID("iss-test-local")
|
|
|
|
return conn, repo
|
|
}
|
|
|
|
// helper: forge an OCSP request for a given serial. The local connector's
|
|
// SignOCSPResponse takes a typed request struct, not raw OCSP bytes.
|
|
func ocspReqFor(serial *big.Int, status int) issuer.OCSPSignRequest {
|
|
now := time.Now().UTC()
|
|
return issuer.OCSPSignRequest{
|
|
CertSerial: serial,
|
|
CertStatus: status,
|
|
ThisUpdate: now,
|
|
NextUpdate: now.Add(24 * time.Hour),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase-2 bootstrap path coverage.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSignOCSPResponse_DedicatedResponder_Bootstrapped(t *testing.T) {
|
|
conn, repo := newConnectorWithResponderDeps(t)
|
|
ctx := context.Background()
|
|
|
|
respBytes, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(0xDEAD), 0))
|
|
if err != nil {
|
|
t.Fatalf("SignOCSPResponse: %v", err)
|
|
}
|
|
if len(respBytes) == 0 {
|
|
t.Fatal("OCSP response is empty")
|
|
}
|
|
|
|
// Verify the responder row was persisted.
|
|
if repo.putCount != 1 {
|
|
t.Errorf("expected exactly 1 Put on first call, got %d", repo.putCount)
|
|
}
|
|
row, _ := repo.Get(ctx, "iss-test-local")
|
|
if row == nil {
|
|
t.Fatal("responder row was not persisted")
|
|
}
|
|
if row.KeyAlg != "ECDSA-P256" {
|
|
t.Errorf("KeyAlg = %q, want ECDSA-P256 (the bootstrap default)", row.KeyAlg)
|
|
}
|
|
if row.NotAfter.Sub(row.NotBefore) < 24*time.Hour {
|
|
t.Errorf("validity window too short: %v", row.NotAfter.Sub(row.NotBefore))
|
|
}
|
|
|
|
// Parse the responder cert and check the OCSP-specific properties.
|
|
block, _ := pem.Decode([]byte(row.CertPEM))
|
|
if block == nil {
|
|
t.Fatal("responder CertPEM is not PEM")
|
|
}
|
|
cert, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
t.Fatalf("parse responder cert: %v", err)
|
|
}
|
|
|
|
// EKU must include OCSPSigning per RFC 6960 §4.2.2.2.
|
|
hasOCSPSigning := false
|
|
for _, eku := range cert.ExtKeyUsage {
|
|
if eku == x509.ExtKeyUsageOCSPSigning {
|
|
hasOCSPSigning = true
|
|
break
|
|
}
|
|
}
|
|
if !hasOCSPSigning {
|
|
t.Error("responder cert missing ExtKeyUsageOCSPSigning")
|
|
}
|
|
|
|
// id-pkix-ocsp-nocheck (RFC 6960 §4.2.2.2.1) — verify the extension OID
|
|
// shows up in the cert's Extensions list. The Go stdlib does not
|
|
// promote this extension into a typed field; check ExtraExtensions
|
|
// equivalent via the raw Extensions slice.
|
|
noCheckOID := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 5}
|
|
hasNoCheck := false
|
|
for _, ext := range cert.Extensions {
|
|
if ext.Id.Equal(noCheckOID) {
|
|
hasNoCheck = true
|
|
break
|
|
}
|
|
}
|
|
if !hasNoCheck {
|
|
t.Error("responder cert missing id-pkix-ocsp-nocheck extension")
|
|
}
|
|
|
|
// The OCSP response should be signed by the responder cert, not by
|
|
// the CA cert. Parse the response with the issuer cert as the trust
|
|
// anchor — ocsp.ParseResponse reads the certificates field from the
|
|
// response itself and verifies the chain back to issuer.
|
|
caPEM, err := conn.GetCACertPEM(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetCACertPEM: %v", err)
|
|
}
|
|
caBlock, _ := pem.Decode([]byte(caPEM))
|
|
caCert, err := x509.ParseCertificate(caBlock.Bytes)
|
|
if err != nil {
|
|
t.Fatalf("parse CA cert: %v", err)
|
|
}
|
|
|
|
parsedResp, err := ocsp.ParseResponse(respBytes, caCert)
|
|
if err != nil {
|
|
t.Fatalf("ParseResponse with CA as issuer: %v", err)
|
|
}
|
|
if parsedResp.SerialNumber.Cmp(big.NewInt(0xDEAD)) != 0 {
|
|
t.Errorf("response serial mismatch: got %v want %v", parsedResp.SerialNumber, 0xDEAD)
|
|
}
|
|
if parsedResp.Status != ocsp.Good {
|
|
t.Errorf("response status = %d, want Good (0)", parsedResp.Status)
|
|
}
|
|
// The response's Certificate field should be the responder cert
|
|
// (NOT the CA cert) — that's the proof the dedicated-responder
|
|
// path was taken.
|
|
if parsedResp.Certificate == nil {
|
|
t.Fatal("OCSP response did not include the responder cert")
|
|
}
|
|
if parsedResp.Certificate.Subject.CommonName == caCert.Subject.CommonName {
|
|
t.Errorf("OCSP response was signed by the CA, not by a dedicated responder cert")
|
|
}
|
|
}
|
|
|
|
func TestSignOCSPResponse_DedicatedResponder_ReusedAcrossCalls(t *testing.T) {
|
|
conn, repo := newConnectorWithResponderDeps(t)
|
|
ctx := context.Background()
|
|
|
|
for i := 0; i < 3; i++ {
|
|
_, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(int64(i+1)), 0))
|
|
if err != nil {
|
|
t.Fatalf("SignOCSPResponse[%d]: %v", i, err)
|
|
}
|
|
}
|
|
// Bootstrap on first call only — subsequent calls should reuse the
|
|
// persisted responder. putCount > 1 means we re-bootstrapped (bug).
|
|
if repo.putCount != 1 {
|
|
t.Errorf("putCount = %d, want 1 (responder should be reused across calls)", repo.putCount)
|
|
}
|
|
}
|
|
|
|
func TestSignOCSPResponse_FallbackPath_NoResponderDeps(t *testing.T) {
|
|
// Construct a connector WITHOUT responder deps wired. SignOCSPResponse
|
|
// must fall back to the historical CA-key-direct path and not error.
|
|
conn := local.New(&local.Config{ValidityDays: 30}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
ctx := context.Background()
|
|
|
|
respBytes, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(0xCAFE), 0))
|
|
if err != nil {
|
|
t.Fatalf("fallback SignOCSPResponse: %v", err)
|
|
}
|
|
if len(respBytes) == 0 {
|
|
t.Fatal("fallback OCSP response is empty")
|
|
}
|
|
// The fallback path uses the CA cert as the responder — the response
|
|
// bytes parse against the CA cert successfully.
|
|
caPEM, err := conn.GetCACertPEM(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetCACertPEM: %v", err)
|
|
}
|
|
block, _ := pem.Decode([]byte(caPEM))
|
|
caCert, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
t.Fatalf("parse CA cert: %v", err)
|
|
}
|
|
if _, err := ocsp.ParseResponse(respBytes, caCert); err != nil {
|
|
t.Fatalf("fallback OCSP response should validate against CA cert: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSignOCSPResponse_DedicatedResponder_RecoversFromCorruptKeyRef(t *testing.T) {
|
|
// Simulate the failure mode where the persisted responder row points
|
|
// at a key the signer driver can't load (e.g., operator deleted the
|
|
// key file out from under us). The bootstrap path should recover by
|
|
// generating a fresh responder rather than failing the OCSP request.
|
|
conn, repo := newConnectorWithResponderDeps(t)
|
|
ctx := context.Background()
|
|
|
|
// Pre-populate the repo with a stale row whose KeyPath the
|
|
// MemoryDriver doesn't know about. MemoryDriver.Load returns an
|
|
// "unknown ref" error for any ref it didn't issue.
|
|
stale := &domain.OCSPResponder{
|
|
IssuerID: "iss-test-local",
|
|
CertPEM: "-----BEGIN CERTIFICATE-----\nbm90LWEtcmVhbC1jZXJ0\n-----END CERTIFICATE-----\n",
|
|
CertSerial: "01",
|
|
KeyPath: "mem-NEVER-ISSUED",
|
|
KeyAlg: "ECDSA-P256",
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(30 * 24 * time.Hour), // far future, NOT in rotation grace
|
|
}
|
|
if err := repo.Put(ctx, stale); err != nil {
|
|
t.Fatalf("seed stale row: %v", err)
|
|
}
|
|
repo.putCount = 0 // reset so the bootstrap-triggered Put is the only one we count
|
|
|
|
// First SignOCSPResponse should detect the bad KeyPath, log a warning,
|
|
// and bootstrap a fresh responder.
|
|
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(0xBEEF), 0)); err != nil {
|
|
t.Fatalf("SignOCSPResponse should recover from corrupt key ref, got: %v", err)
|
|
}
|
|
if repo.putCount != 1 {
|
|
t.Errorf("expected fresh bootstrap on corrupt key ref, putCount=%d", repo.putCount)
|
|
}
|
|
row := repo.rows["iss-test-local"]
|
|
if row.CertSerial == "01" {
|
|
t.Error("responder row was not replaced after corrupt key ref recovery")
|
|
}
|
|
}
|
|
|
|
func TestSignOCSPResponse_DedicatedResponder_KeyDirSetter(t *testing.T) {
|
|
// Pin the SetOCSPResponderKeyDir path. The MemoryDriver doesn't
|
|
// honor the dir (it generates in-memory refs), so this is purely a
|
|
// no-side-effect coverage pin for the setter.
|
|
conn, _ := newConnectorWithResponderDeps(t)
|
|
conn.SetOCSPResponderKeyDir(t.TempDir())
|
|
|
|
if _, err := conn.SignOCSPResponse(context.Background(), ocspReqFor(big.NewInt(7), 0)); err != nil {
|
|
t.Fatalf("SignOCSPResponse with key dir set: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSignOCSPResponse_DedicatedResponder_RecoversFromCorruptCertPEM(t *testing.T) {
|
|
// Companion to the corrupt-key-ref test: this time the key loads
|
|
// fine but the persisted CertPEM is not a CERTIFICATE block. The
|
|
// bootstrap should detect via parseSinglePEMCert and re-issue.
|
|
conn, repo := newConnectorWithResponderDeps(t)
|
|
ctx := context.Background()
|
|
|
|
// Generate a real key via the MemoryDriver so the load succeeds, then
|
|
// pair it with an INVALID cert PEM (PRIVATE KEY block instead of
|
|
// CERTIFICATE). MemoryDriver.Generate stores the key under a fresh
|
|
// "mem-N" ref; we capture that ref by triggering a Generate and
|
|
// pulling the row out of the repo.
|
|
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(1), 0)); err != nil {
|
|
t.Fatalf("seed bootstrap: %v", err)
|
|
}
|
|
row := repo.rows["iss-test-local"]
|
|
row.CertPEM = "-----BEGIN PRIVATE KEY-----\nbm9wZQ==\n-----END PRIVATE KEY-----\n"
|
|
repo.rows["iss-test-local"] = row
|
|
repo.putCount = 0
|
|
|
|
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(2), 0)); err != nil {
|
|
t.Fatalf("SignOCSPResponse should recover from corrupt cert PEM, got: %v", err)
|
|
}
|
|
if repo.putCount != 1 {
|
|
t.Errorf("expected fresh bootstrap on corrupt cert PEM, putCount=%d", repo.putCount)
|
|
}
|
|
}
|
|
|
|
func TestSignOCSPResponse_DedicatedResponder_RotatesWithinGrace(t *testing.T) {
|
|
conn, repo := newConnectorWithResponderDeps(t)
|
|
ctx := context.Background()
|
|
|
|
// Use a short validity + matching grace so the first bootstrap
|
|
// produces a cert that immediately falls inside the rotation
|
|
// window on the next call. validity = 5m, grace = 10m → freshly-
|
|
// bootstrapped cert expires in 5m which is < 10m grace → rotate.
|
|
conn.SetOCSPResponderValidity(5 * time.Minute)
|
|
conn.SetOCSPResponderRotationGrace(10 * time.Minute)
|
|
|
|
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(1), 0)); err != nil {
|
|
t.Fatalf("first SignOCSPResponse: %v", err)
|
|
}
|
|
firstSerial := repo.rows["iss-test-local"].CertSerial
|
|
|
|
// Second call: rotation triggers because the first cert is in the
|
|
// grace window. The new row's RotatedFrom should equal the first
|
|
// cert's serial.
|
|
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(2), 0)); err != nil {
|
|
t.Fatalf("second SignOCSPResponse (rotation): %v", err)
|
|
}
|
|
if repo.putCount < 2 {
|
|
t.Fatalf("expected rotation to trigger a second Put, got putCount=%d", repo.putCount)
|
|
}
|
|
row := repo.rows["iss-test-local"]
|
|
if row.CertSerial == firstSerial {
|
|
t.Errorf("CertSerial unchanged across rotation: %q", row.CertSerial)
|
|
}
|
|
if row.RotatedFrom != firstSerial {
|
|
t.Errorf("RotatedFrom = %q, want %q (the first cert's serial)", row.RotatedFrom, firstSerial)
|
|
}
|
|
}
|