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