From acc73973d510ee3bb471a8f7611b53109adac872 Mon Sep 17 00:00:00 2001 From: Alex Wicks Date: Sun, 11 Oct 2026 23:54:16 +0100 Subject: [PATCH] feat: Enforce issuer validity limits and warnings Bound local X.509 expiration by the selected signing path and its matching roots, defaulting to truncation with an explicit reject policy. Preserve policy selection in generated SoftCAS configurations and enforce before signing or subordinate-key creation. Carry successful truncation warnings through native and SSH identity responses, request-correlated logs, internal issuance, and StepCAS. Map typed policy rejections to native, ACME, and SCEP responses without changing their successful protocol payloads. Document backend scope, below-minimum validity, startup and renewal risks. Cover real authorities, path rotation, persistence ordering, protocol responses, warning isolation, and generated configuration roundtrips. See #381, #560 --- CHANGELOG.md | 19 + README.md | 73 +++ acme/issuer_validity_test.go | 281 ++++++++++ acme/order.go | 6 + api/certificate_warning.go | 18 + api/certificate_warning_ssh_test.go | 147 +++++ api/certificate_warning_test.go | 500 ++++++++++++++++++ api/rekey.go | 6 +- api/renew.go | 5 +- api/sign.go | 15 +- api/ssh.go | 20 +- api/sshRekey.go | 12 +- api/sshRenew.go | 16 +- authority/authority.go | 17 + authority/authority_test.go | 5 +- authority/authorize_test.go | 17 +- authority/certificate_warning_test.go | 141 +++++ authority/config/issuer_validity_test.go | 26 + authority/issuer_validity_test.go | 168 ++++++ authority/tls.go | 22 + authority/tls_test.go | 11 +- cas/apiv1/certificate_warning.go | 38 ++ cas/apiv1/issuer_validity.go | 28 + cas/apiv1/issuer_validity_test.go | 27 + cas/apiv1/options.go | 29 + cas/apiv1/requests.go | 3 + cas/apiv1/services.go | 7 + cas/softcas/issuer_validity.go | 160 ++++++ cas/softcas/issuer_validity_authority_test.go | 189 +++++++ cas/softcas/issuer_validity_errors_test.go | 166 ++++++ cas/softcas/issuer_validity_rotation_test.go | 153 ++++++ cas/softcas/issuer_validity_seconds_test.go | 47 ++ cas/softcas/issuer_validity_test.go | 234 ++++++++ cas/softcas/softcas.go | 74 ++- cas/stepcas/certificate_warning_test.go | 184 +++++++ cas/stepcas/stepcas.go | 23 +- logging/certificate_warning.go | 18 + logging/certificate_warning_test.go | 93 ++++ logging/handler.go | 16 +- pki/certificate_warning_test.go | 85 +++ pki/pki.go | 18 +- pki/pki_test.go | 44 ++ scep/api/api.go | 8 +- scep/api/issuer_validity_test.go | 327 ++++++++++++ 44 files changed, 3423 insertions(+), 73 deletions(-) create mode 100644 acme/issuer_validity_test.go create mode 100644 api/certificate_warning.go create mode 100644 api/certificate_warning_ssh_test.go create mode 100644 api/certificate_warning_test.go create mode 100644 authority/certificate_warning_test.go create mode 100644 authority/config/issuer_validity_test.go create mode 100644 authority/issuer_validity_test.go create mode 100644 cas/apiv1/certificate_warning.go create mode 100644 cas/apiv1/issuer_validity.go create mode 100644 cas/apiv1/issuer_validity_test.go create mode 100644 cas/softcas/issuer_validity.go create mode 100644 cas/softcas/issuer_validity_authority_test.go create mode 100644 cas/softcas/issuer_validity_errors_test.go create mode 100644 cas/softcas/issuer_validity_rotation_test.go create mode 100644 cas/softcas/issuer_validity_seconds_test.go create mode 100644 cas/softcas/issuer_validity_test.go create mode 100644 cas/stepcas/certificate_warning_test.go create mode 100644 logging/certificate_warning.go create mode 100644 logging/certificate_warning_test.go create mode 100644 pki/certificate_warning_test.go create mode 100644 scep/api/issuer_validity_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 29108dda9..700c5600d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Support for ML-DSA (FIPS 204) keys, available when built with Go 1.27 or newer. JOSE-based flows (JWK, JWS and JWT tokens) do not support ML-DSA yet. +- Add configurable `authority.issuerValidityLimit` truncation/rejection for + local X.509 issuance ([smallstep/certificates#381](https://github.com/smallstep/certificates/issues/381)) +- Return structured native issuance warnings and log successful truncation at + warning level, including SSH X.509 identities and StepCAS delegation + ([smallstep/certificates#381](https://github.com/smallstep/certificates/issues/381)) + +### Changed + +- Bound local X.509 expiry by the selected issuing CA path and matching root by + default, including renewal/rekey and subordinate CA creation + ([smallstep/certificates#381](https://github.com/smallstep/certificates/issues/381)) ### Changed @@ -42,6 +53,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Go 1.25 support. Go 1.26 or newer is now required. +### Fixed + +- Present issuer-validity policy rejections as ACME `badCSR` and SCEP failure + CertRep responses with safe diagnostics, rather than generic internal errors + ([smallstep/certificates#381](https://github.com/smallstep/certificates/issues/381)) +- Preserve explicit issuer-validity policies in generated SoftCAS configurations + ([smallstep/certificates#560](https://github.com/smallstep/certificates/issues/560)). + ## [0.30.2] - 2026-03-22 - Update golang.org/grpc to patch security advisory diff --git a/README.md b/README.md index 1b70f0586..818c414c4 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,79 @@ ACME is the protocol used by Let's Encrypt to automate the issuance of HTTPS cer - [Install root certificates](https://smallstep.com/docs/step-cli/reference/certificate/install/) on your machine and browsers, so your CA is trusted - [Inspect](https://smallstep.com/docs/step-cli/reference/certificate/inspect/) and [lint](https://smallstep.com/docs/step-cli/reference/certificate/lint/) certificates +### Issuer validity limits + +Local SoftCAS issuance bounds X.509 expiration to the selected signing path, +including its applicable configured root, rather than every root in the trust +store. This applies to signing, renewal/rekey, SSH-associated X.509 identities, +and subordinate CA creation. Returned issuer bundles are unchanged. + +The optional `authority.issuerValidityLimit` setting accepts `truncate` (the +default) or `reject`: + +```json +{ + "authority": { + "issuerValidityLimit": "truncate" + } +} +``` + +`truncate` reduces only the resolved `NotAfter`, after the original request has +passed provisioner validation. A positive remaining interval may be shorter +than `minTLSCertDuration`; an already-expired or non-positive interval is always +rejected. Equal or earlier requested expiry is unchanged. + +`reject` refuses an expiry beyond the issuing path with native HTTP 403 and a +safe chain-expiry diagnostic. It also applies to the CA's own 24-hour TLS +certificate: restarting near issuer expiry can fail, without a truncation +exemption. There is no per-request override or `off` mode. + +Direct standalone SoftCAS callers without roots are limited to their supplied +issuer path; initialized `step-ca` supplies configured roots separately. +Bundles must be ordered, cryptographically linked paths. This is expiry +enforcement, not new PKIX/name-constraint or ancestor-start-date validation. +Other CAS backends retain their behavior when the setting is omitted and refuse +an explicit unsupported policy. A StepCAS RA relies on the issuing CA's policy, +not its TLS trust chain. + +Successful truncation emits a server warning and, for native sign/renew/rekey +and SSH X.509 identity responses, an optional `warnings` array. Warning-free +responses omit it. For example (certificate fields omitted): + +```json +{ + "warnings": [{ + "code": "issuer_validity_truncated", + "message": "Certificate expiration was reduced from 2026-10-13T00:00:00Z to 2026-10-10T12:00:00Z because the issuing CA chain expires then. Contact the CA operator to renew the limiting CA certificate.", + "requestedNotAfter": "2026-10-13T00:00:00Z", + "effectiveNotAfter": "2026-10-10T12:00:00Z", + "limitingCertificate": { + "subject": "CN=Example root CA", + "fingerprint": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "notAfter": "2026-10-10T12:00:00Z" + } + }] +} +``` + +`requestedNotAfter` is the resolved expiry before limiting, including defaults, +templates, or renewal arithmetic. Dates are UTC with certificate-second +precision; the fingerprint is lowercase SHA-256 of the limiting certificate +DER, distinguishing same-subject rollovers. A configured HTTP logger emits one +WARN access record with `certificate-warnings` and the existing request +correlation fields; non-HTTP TLS/PKI issuance uses a standard warning log. +StepCAS forwards actual upstream warnings unchanged. ACME/SCEP preserve their +standard successful certificate payloads and server-side warning logs, but +arbitrary protocol clients cannot be promised to display native warnings. +The coordinated `step` CLI prints warnings once on stderr after successful +credential handling, without contaminating PEM/stdout or changing success. + +Truncation does not renew a CA or repair existing certificates. Renewal retains +the previous leaf duration, so a shortened duration can persist after CA +rotation. Fixed CLI renewal periods longer than the remaining lifetime can +cause immediate repeated renewals near expiry; scheduling policy is unchanged. + ## Installation See our installation docs [here](https://smallstep.com/docs/step-ca/installation). diff --git a/acme/issuer_validity_test.go b/acme/issuer_validity_test.go new file mode 100644 index 000000000..36fc57eb0 --- /dev/null +++ b/acme/issuer_validity_test.go @@ -0,0 +1,281 @@ +package acme_test + +import ( + "bytes" + "context" + "crypto" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + stdlog "log" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.step.sm/crypto/jose" + "go.step.sm/crypto/keyutil" + "go.step.sm/crypto/minica" + "go.step.sm/crypto/x509util" + + "github.com/smallstep/certificates/acme" + acmeapi "github.com/smallstep/certificates/acme/api" + acmedb "github.com/smallstep/certificates/acme/db/nosql" + "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/config" + "github.com/smallstep/certificates/authority/provisioner" + casapi "github.com/smallstep/certificates/cas/apiv1" + "github.com/smallstep/certificates/db" + "github.com/smallstep/certificates/logging" + "github.com/smallstep/certificates/middleware/requestid" +) + +type issuerValiditySigner struct { + crypto.Signer + calls int + fail bool +} + +func (s *issuerValiditySigner) Sign(random io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + s.calls++ + if s.fail { + return nil, errors.New("private signer unavailable") + } + return s.Signer.Sign(random, digest, opts) +} + +func issuerValidityCA(t *testing.T, now, limit time.Time) *minica.CA { + t.Helper() + ca, err := minica.New() + require.NoError(t, err) + root := *ca.Root + root.NotBefore, root.NotAfter = now.Add(-time.Hour), limit + ca.Root, err = x509util.CreateCertificate(&root, &root, ca.RootSigner.Public(), ca.RootSigner) + require.NoError(t, err) + intermediate := *ca.Intermediate + intermediate.NotBefore, intermediate.NotAfter = now.Add(-time.Hour), now.Add(96*time.Hour) + ca.Intermediate, err = x509util.CreateCertificate(&intermediate, ca.Root, ca.Signer.Public(), ca.RootSigner) + require.NoError(t, err) + require.NoError(t, ca.Intermediate.CheckSignatureFrom(ca.Root)) + return ca +} + +// Exercise the real authenticated ACME router, authority, SoftCAS and database. +// The account and completed authorizations are pre-created so no DNS is required. +func TestIssuerValidityLimit(t *testing.T) { + for _, tc := range []struct { + name string + mode casapi.IssuerValidityLimit + requested time.Duration + remaining time.Duration + fail bool + status int + warning bool + reason string + fallback bool + }{ + {"default truncates", "", 72 * time.Hour, 36 * time.Hour, false, http.StatusOK, true, "", false}, + {"explicit truncate", casapi.IssuerValidityLimitTruncate, 72 * time.Hour, 36 * time.Hour, false, http.StatusOK, true, "", false}, + {"below minimum remains usable", "", 72 * time.Hour, time.Minute, false, http.StatusOK, true, "", false}, + {"without logger warns through fallback", "", 72 * time.Hour, 36 * time.Hour, false, http.StatusOK, true, "", true}, + {"equal expiry stays silent", "", 36 * time.Hour, 36 * time.Hour, false, http.StatusOK, false, "", false}, + {"earlier expiry stays silent", "", time.Hour, 36 * time.Hour, false, http.StatusOK, false, "", false}, + {"reject overrun", casapi.IssuerValidityLimitReject, 72 * time.Hour, 36 * time.Hour, false, http.StatusBadRequest, false, "exceeds_limit", false}, + {"reject permits earlier expiry", casapi.IssuerValidityLimitReject, time.Hour, 36 * time.Hour, false, http.StatusOK, false, "", false}, + {"expired root rejects", "", 72 * time.Hour, -time.Minute, false, http.StatusBadRequest, false, "no_remaining_validity", false}, + {"signer error remains internal", "", 72 * time.Hour, 36 * time.Hour, true, http.StatusInternalServerError, false, "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + limit, requested := now.Add(tc.remaining), now.Add(tc.requested) + ca := issuerValidityCA(t, now, limit) + signer := &issuerValiditySigner{Signer: ca.Signer, fail: tc.fail} + prov := &provisioner.ACME{Type: "ACME", Name: "issuer-validity", Claims: &provisioner.Claims{ + MaxTLSDur: &provisioner.Duration{Duration: 96 * time.Hour}, + }} + a, err := authority.NewEmbedded( + authority.WithConfig(&config.Config{ + DB: &db.Config{Type: "bbolt", DataSource: filepath.Join(t.TempDir(), "ca.db")}, + AuthorityConfig: &config.AuthConfig{ + Options: &casapi.Options{IssuerValidityLimit: tc.mode}, + Provisioners: provisioner.List{prov}, + }, + }), + authority.WithX509RootCerts(ca.Root), + authority.WithX509Signer(ca.Intermediate, signer), + authority.WithQuietInit(), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, a.Shutdown()) }) + store, err := acmedb.New(a.GetDatabase().(*db.DB)) + require.NoError(t, err) + ctx := context.Background() + jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + public := jwk.Public() + account := &acme.Account{ + Key: &public, Status: acme.StatusValid, + ProvisionerID: prov.GetID(), ProvisionerName: prov.GetName(), + LocationPrefix: "https://ca.local/acme/issuer-validity/account/", + } + require.NoError(t, store.CreateAccount(ctx, account)) + order := &acme.Order{ + AccountID: account.ID, ProvisionerID: prov.GetID(), Status: acme.StatusReady, + ExpiresAt: now.Add(time.Hour), NotBefore: now, NotAfter: requested, + Identifiers: []acme.Identifier{{Type: acme.DNS, Value: "leaf.example"}}, + } + require.NoError(t, store.CreateOrder(ctx, order)) + key, err := keyutil.GenerateDefaultSigner() + require.NoError(t, err) + csr, err := x509util.CreateCertificateRequest("leaf.example", []string{"leaf.example"}, key) + require.NoError(t, err) + payload, err := json.Marshal(acmeapi.FinalizeRequest{CSR: base64.RawURLEncoding.EncodeToString(csr.Raw)}) + require.NoError(t, err) + + router := chi.NewRouter() + router.Route("/acme", func(r chi.Router) { + acmeapi.NewHandler(acmeapi.HandlerOptions{DB: store, CA: a, DNS: "ca.local", Prefix: "acme"}).Route(r) + }) + logger, err := logging.New("acme", json.RawMessage(`{"format":"json"}`)) + require.NoError(t, err) + var logs bytes.Buffer + logger.SetOutput(&logs) + handler := requestid.New("").Middleware(logger.Middleware(router)) + if tc.fallback { + handler = router + previous := stdlog.Writer() + stdlog.SetOutput(&logs) + t.Cleanup(func() { stdlog.SetOutput(previous) }) + } + url := fmt.Sprintf("https://ca.local/acme/issuer-validity/order/%s/finalize", order.ID) + req := issuerValidityACMERequest(t, store, jwk, account.GetLocation(), url, payload) + req.Header.Set("X-Request-Id", tc.name) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + require.Equal(t, tc.status, w.Code, w.Body.String()) + if tc.fallback { + assert.Equal(t, 1, bytes.Count(logs.Bytes(), []byte("WARNING:"))) + assert.Equal(t, 1, bytes.Count(logs.Bytes(), []byte("\n"))) + assert.Contains(t, logs.String(), "issuer_validity_truncated") + assert.Contains(t, logs.String(), limit.Format(time.RFC3339)) + } else { + var record map[string]json.RawMessage + decoder := json.NewDecoder(&logs) + require.NoError(t, decoder.Decode(&record)) + var extra map[string]json.RawMessage + require.ErrorIs(t, decoder.Decode(&extra), io.EOF, "one access log, not an extra warning line") + assert.JSONEq(t, fmt.Sprintf("%q", tc.name), string(record["request-id"])) + if tc.warning { + assert.JSONEq(t, `"warning"`, string(record["level"])) + var warnings []casapi.CertificateWarning + require.NoError(t, json.Unmarshal(record["certificate-warnings"], &warnings)) + require.Len(t, warnings, 1) + assert.Equal(t, "issuer_validity_truncated", warnings[0].Code) + assert.Equal(t, requested, warnings[0].RequestedNotAfter) + assert.Equal(t, limit, warnings[0].EffectiveNotAfter) + hash := sha256.Sum256(ca.Root.Raw) + assert.Equal(t, casapi.LimitingCertificate{Subject: ca.Root.Subject.String(), Fingerprint: hex.EncodeToString(hash[:]), NotAfter: limit}, warnings[0].LimitingCertificate) + } else { + assert.NotContains(t, record, "certificate-warnings") + level := "info" + if tc.status >= 500 { + level = "error" + } else if tc.status >= 400 { + level = "warning" + } + assert.JSONEq(t, fmt.Sprintf("%q", level), string(record["level"])) + } + } + storedOrder, err := store.GetOrder(ctx, order.ID) + require.NoError(t, err) + if tc.status != http.StatusOK { + assert.Equal(t, "application/problem+json", w.Header().Get("Content-Type")) + var problem acme.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &problem)) + assert.Equal(t, acme.StatusReady, storedOrder.Status) + assert.Empty(t, storedOrder.CertificateID) + if tc.reason != "" { + assert.Zero(t, signer.calls, "policy rejection occurs before private signing") + assert.Equal(t, "urn:ietf:params:acme:error:badCSR", problem.Type) + // Independently spell the expected problem detail instead of + // re-running the production formatter, so a formatter + // regression that drops a timestamp cannot pass unnoticed. + var wantDetail string + switch tc.reason { + case "exceeds_limit": + wantDetail = fmt.Sprintf("requested certificate expiration %s exceeds issuing CA chain expiration %s", + requested.UTC().Truncate(time.Second).Format(time.RFC3339), limit.UTC().Truncate(time.Second).Format(time.RFC3339)) + case "no_remaining_validity": + wantDetail = fmt.Sprintf("issuing CA chain expiration %s leaves no usable certificate validity; contact the CA operator", + limit.UTC().Truncate(time.Second).Format(time.RFC3339)) + } + assert.Equal(t, "The CSR is unacceptable: "+wantDetail, problem.Detail) + } else { + assert.Equal(t, 1, signer.calls) + assert.Equal(t, "urn:ietf:params:acme:error:serverInternal", problem.Type) + assert.NotContains(t, problem.Detail, "private signer unavailable") + } + return + } + + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &fields)) + assert.NotContains(t, fields, "warnings", "ACME does not add native warning extensions") + assert.Equal(t, acme.StatusValid, storedOrder.Status) + storedCert, err := store.GetCertificate(ctx, storedOrder.CertificateID) + require.NoError(t, err) + expectedExpiry := requested + if tc.warning { + expectedExpiry = limit + } + assert.Equal(t, expectedExpiry, storedCert.Leaf.NotAfter) + assert.Equal(t, now, storedCert.Leaf.NotBefore) + assert.Equal(t, []string{"leaf.example"}, storedCert.Leaf.DNSNames) + require.NoError(t, storedCert.Leaf.CheckSignatureFrom(ca.Intermediate)) + roots, intermediates := x509.NewCertPool(), x509.NewCertPool() + roots.AddCert(ca.Root) + intermediates.AddCert(ca.Intermediate) + _, err = storedCert.Leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: intermediates, DNSName: "leaf.example", CurrentTime: now}) + require.NoError(t, err) + downloadURL := fmt.Sprintf("https://ca.local/acme/issuer-validity/certificate/%s", storedOrder.CertificateID) + download := httptest.NewRecorder() + // A POST-as-GET carries an explicitly empty payload; nil produces a detached JWS. + handler.ServeHTTP(download, issuerValidityACMERequest(t, store, jwk, account.GetLocation(), downloadURL, []byte{})) + require.Equal(t, http.StatusOK, download.Code, download.Body.String()) + assert.Equal(t, "application/pem-certificate-chain", download.Header().Get("Content-Type")) + block, rest := pem.Decode(download.Body.Bytes()) + require.NotNil(t, block) + assert.Equal(t, storedCert.Leaf.Raw, block.Bytes) + block, rest = pem.Decode(rest) + require.NotNil(t, block) + assert.Equal(t, ca.Intermediate.Raw, block.Bytes) + assert.Empty(t, bytes.TrimSpace(rest), "certificate payload contains neither root nor warning metadata") + }) + } +} + +func issuerValidityACMERequest(t *testing.T, store acme.DB, key *jose.JSONWebKey, kid, url string, payload []byte) *http.Request { + t.Helper() + nonce, err := store.CreateNonce(context.Background()) + require.NoError(t, err) + opts := new(jose.SignerOptions) + opts.WithHeader("kid", kid).WithHeader("nonce", string(nonce)).WithHeader("url", url) + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key.Key}, opts) + require.NoError(t, err) + signed, err := signer.Sign(payload) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewBufferString(signed.FullSerialize())) + req.Header.Set("Content-Type", "application/jose+json") + return req +} diff --git a/acme/order.go b/acme/order.go index 875d3aa5b..892bb7aa8 100644 --- a/acme/order.go +++ b/acme/order.go @@ -20,6 +20,7 @@ import ( "github.com/smallstep/certificates/acme/wire" "github.com/smallstep/certificates/authority/provisioner" + casapi "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/webhook" ) @@ -316,6 +317,11 @@ func (o *Order) Finalize(ctx context.Context, db DB, csr *x509.CertificateReques return acmeError } + var validityErr *casapi.IssuerValidityError + if errors.As(err, &validityErr) { + return NewDetailedError(ErrorBadCSRType, "%s", validityErr.Error()) + } + return WrapErrorISE(err, "error signing certificate for order %s", o.ID) } diff --git a/api/certificate_warning.go b/api/certificate_warning.go new file mode 100644 index 000000000..bdb8f9719 --- /dev/null +++ b/api/certificate_warning.go @@ -0,0 +1,18 @@ +package api + +import ( + "context" + + "github.com/smallstep/certificates/cas/apiv1" + "github.com/smallstep/certificates/logging" +) + +func collectCertificateWarnings(ctx context.Context) (context.Context, *[]apiv1.CertificateWarning) { + parentCtx := ctx + var warnings []apiv1.CertificateWarning + ctx = apiv1.WithCertificateWarningHandler(ctx, func(warning apiv1.CertificateWarning) { + warnings = append(warnings, warning) + logging.LogCertificateWarning(parentCtx, warning) + }) + return ctx, &warnings +} diff --git a/api/certificate_warning_ssh_test.go b/api/certificate_warning_ssh_test.go new file mode 100644 index 000000000..b996f5281 --- /dev/null +++ b/api/certificate_warning_ssh_test.go @@ -0,0 +1,147 @@ +package api + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" + logtest "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.step.sm/crypto/jose" + "go.step.sm/crypto/x509util" + "golang.org/x/crypto/ssh" + + "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/cas/apiv1" + "github.com/smallstep/certificates/logging" +) + +func TestIssuerValidityWarnings_SSHIdentity(t *testing.T) { + f := newWarningAuthority(t, "") + _, identityKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + csr, err := x509util.CreateCertificateRequest("warning.example", []string{"warning.example"}, identityKey) + require.NoError(t, err) + _, sshKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + sshPublicKey, err := ssh.NewPublicKey(sshKey.Public()) + require.NoError(t, err) + sshOptions := &provisioner.SignSSHOptions{ + CertType: provisioner.SSHHostCert, KeyID: "warning.example", Principals: []string{"warning.example"}, + ValidAfter: NewTimeDuration(f.now), ValidBefore: NewTimeDuration(f.now.Add(72 * time.Hour)), + } + + var original SSHSignResponse + for _, identity := range []bool{true, false} { + t.Run("sign identity="+strconv.FormatBool(identity), func(t *testing.T) { + request := SSHSignRequest{ + PublicKey: sshPublicKey.Marshal(), OTT: f.token(t, "/ssh/sign", sshOptions), + CertType: sshOptions.CertType, KeyID: sshOptions.KeyID, Principals: sshOptions.Principals, + ValidAfter: sshOptions.ValidAfter, ValidBefore: sshOptions.ValidBefore, + } + if identity { + request.IdentityCSR = NewCertificateRequest(csr) + } + r := f.request(t, "/ssh/sign", request, nil) + logger, hook := logtest.NewNullLogger() + w := httptest.NewRecorder() + logging.NewLoggerHandler("test", &logging.Logger{Logger: logger}, http.HandlerFunc(SSHSign)).ServeHTTP(w, r) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + var response SSHSignResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.NotNil(t, response.Certificate.Certificate) + checker := &ssh.CertChecker{} + require.NoError(t, checker.CheckCert("warning.example", response.Certificate.Certificate)) + assert.Equal(t, uint64(f.now.Add(72*time.Hour).Unix()), response.Certificate.ValidBefore) + require.Len(t, hook.AllEntries(), 1) + if identity { + require.Len(t, response.IdentityCertificate, 2) + assertIssuedWarning(t, response.IdentityCertificate[0].Certificate, f.issuer, f.root, response.Warnings) + assert.Equal(t, identityKey.Public(), response.IdentityCertificate[0].PublicKey) + assert.Equal(t, logrus.WarnLevel, hook.LastEntry().Level) + assert.Equal(t, response.Warnings, hook.LastEntry().Data["certificate-warnings"]) + original = response + } else { + assert.Empty(t, response.IdentityCertificate) + assert.Empty(t, response.Warnings) + assert.NotContains(t, hook.LastEntry().Data, "certificate-warnings") + assert.Equal(t, logrus.InfoLevel, hook.LastEntry().Level) + } + }) + } + require.NotNil(t, original.Certificate.Certificate) + + for _, operation := range []string{"renew", "rekey"} { + for _, identity := range []bool{true, false} { + t.Run(operation+" identity="+strconv.FormatBool(identity), func(t *testing.T) { + path := "/ssh/" + operation + claims := jose.Claims{ + ID: uuid.NewString(), Issuer: "sshpop", Subject: strconv.FormatUint(original.Certificate.Serial, 10), + Audience: []string{"https://warning.example" + path + "#sshpop/sshpop"}, + NotBefore: jose.NewNumericDate(f.now), Expiry: jose.NewNumericDate(f.now.Add(time.Minute)), + } + token := warningToken(t, sshKey, new(jose.SignerOptions).WithType("JWT").WithHeader("sshpop", base64.StdEncoding.EncodeToString(original.Certificate.Marshal())), claims) + var body any = SSHRenewRequest{OTT: token} + var handler http.Handler = http.HandlerFunc(SSHRenew) + wantKey := sshPublicKey + if operation == "rekey" { + _, newKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + wantKey, err = ssh.NewPublicKey(newKey.Public()) + require.NoError(t, err) + body, handler = SSHRekeyRequest{OTT: token, PublicKey: wantKey.Marshal()}, http.HandlerFunc(SSHRekey) + } + var peer *x509.Certificate + if identity { + peer = original.IdentityCertificate[0].Certificate + } + r := f.request(t, path, body, peer) + logger, hook := logtest.NewNullLogger() + w := httptest.NewRecorder() + logging.NewLoggerHandler("test", &logging.Logger{Logger: logger}, handler).ServeHTTP(w, r) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + var cert *ssh.Certificate + var certs []Certificate + var warnings []apiv1.CertificateWarning + if operation == "renew" { + var response SSHRenewResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + cert, certs, warnings = response.Certificate.Certificate, response.IdentityCertificate, response.Warnings + } else { + var response SSHRekeyResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + cert, certs, warnings = response.Certificate.Certificate, response.IdentityCertificate, response.Warnings + } + require.NotNil(t, cert) + checker := &ssh.CertChecker{} + require.NoError(t, checker.CheckCert("warning.example", cert)) + assert.Equal(t, wantKey.Marshal(), cert.Key.Marshal()) + assert.Equal(t, original.Certificate.ValidBefore-original.Certificate.ValidAfter, cert.ValidBefore-cert.ValidAfter) + assert.Greater(t, cert.ValidBefore, uint64(f.root.NotAfter.Unix())) + require.Len(t, hook.AllEntries(), 1) + if identity { + require.Len(t, certs, 2) + assertIssuedWarning(t, certs[0].Certificate, f.issuer, f.root, warnings) + assert.Equal(t, identityKey.Public(), certs[0].PublicKey) + assert.Equal(t, logrus.WarnLevel, hook.LastEntry().Level) + assert.Equal(t, warnings, hook.LastEntry().Data["certificate-warnings"]) + } else { + assert.Empty(t, certs) + assert.Empty(t, warnings) + assert.Equal(t, logrus.InfoLevel, hook.LastEntry().Level) + assert.NotContains(t, hook.LastEntry().Data, "certificate-warnings") + } + }) + } + } +} diff --git a/api/certificate_warning_test.go b/api/certificate_warning_test.go new file mode 100644 index 000000000..e40d32ebb --- /dev/null +++ b/api/certificate_warning_test.go @@ -0,0 +1,500 @@ +package api + +import ( + "bytes" + "context" + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/hex" + "encoding/json" + "log" + "math/big" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" + logtest "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.step.sm/crypto/jose" + "go.step.sm/crypto/x509util" + + "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/config" + "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/cas/apiv1" + "github.com/smallstep/certificates/logging" + "github.com/smallstep/certificates/middleware/requestid" +) + +type warningAuthority struct { + authority *authority.Authority + root *x509.Certificate + rootKey crypto.Signer + issuer *x509.Certificate + issuerKey crypto.Signer + tokenKey crypto.Signer + now time.Time +} + +func newWarningAuthority(t *testing.T, mode apiv1.IssuerValidityLimit) *warningAuthority { + t.Helper() + f := &warningAuthority{now: time.Now().UTC().Truncate(time.Second)} + _, rootKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + f.rootKey = rootKey + _, f.issuerKey, err = ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + _, f.tokenKey, err = ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + f.root = warningCACertificate(t, "Warning Root", f.now.Add(36*time.Hour), nil, rootKey, rootKey, f.now) + f.issuer = warningCACertificate(t, "Warning Issuer", f.now.Add(48*time.Hour), f.root, f.issuerKey, rootKey, f.now) + enableSSH := true + claims := &provisioner.Claims{ + MinTLSDur: &provisioner.Duration{Duration: time.Minute}, + DefaultTLSDur: &provisioner.Duration{Duration: 72 * time.Hour}, + MaxTLSDur: &provisioner.Duration{Duration: 168 * time.Hour}, + DefaultHostSSHDur: &provisioner.Duration{Duration: 72 * time.Hour}, + MaxHostSSHDur: &provisioner.Duration{Duration: 168 * time.Hour}, + EnableSSHCA: &enableSSH, + } + f.authority, err = authority.NewEmbedded( + authority.WithConfig(&config.Config{ + DNSNames: []string{"warning.example"}, + AuthorityConfig: &config.AuthConfig{ + Options: &apiv1.Options{IssuerValidityLimit: mode}, + Provisioners: provisioner.List{ + &provisioner.JWK{Type: "JWK", Name: "warning", Key: &jose.JSONWebKey{KeyID: "warning-key", Key: f.tokenKey.Public()}, Claims: claims}, + &provisioner.SSHPOP{Type: "SSHPOP", Name: "sshpop", Claims: claims}, + }, + }, + }), + authority.WithX509RootCerts(f.root), + authority.WithX509SignerFunc(func() ([]*x509.Certificate, crypto.Signer, error) { + return []*x509.Certificate{f.issuer}, f.issuerKey, nil + }), + authority.WithSSHHostSigner(f.issuerKey), + authority.WithSSHUserSigner(f.issuerKey), + authority.WithQuietInit(), + ) + require.NoError(t, err) + return f +} + +func warningCACertificate(t *testing.T, name string, expiry time.Time, parent *x509.Certificate, key, signer crypto.Signer, now time.Time) *x509.Certificate { + t.Helper() + template := &x509.Certificate{ + SerialNumber: big.NewInt(expiry.Unix()), Subject: pkix.Name{CommonName: name}, + NotBefore: now.Add(-time.Hour), NotAfter: expiry, IsCA: true, + BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + } + if parent == nil { + parent = template + } + der, err := x509.CreateCertificate(rand.Reader, template, parent, key.Public(), signer) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + +func warningToken(t *testing.T, key crypto.Signer, opts *jose.SignerOptions, claims any) string { + t.Helper() + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.EdDSA, Key: key}, opts) + require.NoError(t, err) + token, err := jose.Signed(signer).Claims(claims).CompactSerialize() + require.NoError(t, err) + return token +} + +func (f *warningAuthority) token(t *testing.T, path string, sshOptions *provisioner.SignSSHOptions) string { + t.Helper() + claims := map[string]any{ + "jti": uuid.NewString(), "iss": "warning", "sub": "warning.example", + "aud": []string{"https://warning.example" + path}, + "nbf": f.now.Unix(), "exp": f.now.Add(time.Minute).Unix(), + "sans": []string{"warning.example"}, + } + if sshOptions != nil { + claims["step"] = map[string]any{"ssh": sshOptions} + // The same token also authorizes the X.509 identity. + claims["aud"] = []string{"https://warning.example" + path, "https://warning.example/sign"} + } + return warningToken(t, f.tokenKey, new(jose.SignerOptions).WithType("JWT").WithHeader("kid", "warning-key"), claims) +} + +func (f *warningAuthority) request(t *testing.T, path string, body any, peer *x509.Certificate) *http.Request { + t.Helper() + r := httptest.NewRequest(http.MethodPost, "https://warning.example"+path, bytes.NewReader(mustJSON(t, body))) + r = r.WithContext(authority.NewContext(requestid.NewContext(r.Context(), uuid.NewString()), f.authority)) + if peer != nil { + r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{peer}} + } + return r +} + +func assertIssuedWarning(t *testing.T, leaf, issuer, limiting *x509.Certificate, warnings []apiv1.CertificateWarning) { + t.Helper() + require.NoError(t, leaf.CheckSignatureFrom(issuer)) + assert.Equal(t, limiting.NotAfter, leaf.NotAfter) + require.Len(t, warnings, 1) + warning := warnings[0] + assert.Equal(t, "issuer_validity_truncated", warning.Code) + assert.True(t, warning.RequestedNotAfter.After(warning.EffectiveNotAfter)) + assert.Equal(t, leaf.NotAfter, warning.EffectiveNotAfter) + assert.Equal(t, limiting.NotAfter, warning.LimitingCertificate.NotAfter) + assert.Equal(t, limiting.Subject.String(), warning.LimitingCertificate.Subject) + fingerprint := sha256.Sum256(limiting.Raw) + assert.Equal(t, hex.EncodeToString(fingerprint[:]), warning.LimitingCertificate.Fingerprint) +} + +func TestIssuerValidityWarnings(t *testing.T) { + for _, configuredLogger := range []bool{false, true} { + t.Run(strconv.FormatBool(configuredLogger), func(t *testing.T) { + f := newWarningAuthority(t, "") + _, key, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + csr, err := x509util.CreateCertificateRequest("warning.example", []string{"warning.example"}, key) + require.NoError(t, err) + logger, hook := logtest.NewNullLogger() + var fallback bytes.Buffer + previous := log.Writer() + log.SetOutput(&fallback) + t.Cleanup(func() { log.SetOutput(previous) }) + for _, expiry := range []time.Time{f.now.Add(72 * time.Hour), f.now.Add(time.Hour), f.root.NotAfter} { + fallback.Reset() + hook.Reset() + request := f.request(t, "/sign", SignRequest{CsrPEM: NewCertificateRequest(csr), OTT: f.token(t, "/sign", nil), NotAfter: NewTimeDuration(expiry)}, nil) + var handler http.Handler = http.HandlerFunc(Sign) + if configuredLogger { + handler = logging.NewLoggerHandler("test", &logging.Logger{Logger: logger}, handler) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, request) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + var response SignResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.NoError(t, response.ServerPEM.CheckSignatureFrom(f.issuer)) + if expiry.After(f.root.NotAfter) { + assertIssuedWarning(t, response.ServerPEM.Certificate, f.issuer, f.root, response.Warnings) + assert.Equal(t, expiry, response.Warnings[0].RequestedNotAfter) + } else { + assert.Equal(t, expiry, response.ServerPEM.NotAfter) + assert.Empty(t, response.Warnings) + var payload map[string]json.RawMessage + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) + assert.NotContains(t, payload, "warnings") + } + if configuredLogger { + require.Len(t, hook.AllEntries(), 1) + entry := hook.LastEntry() + id, _ := requestid.FromContext(request.Context()) + assert.Equal(t, id, entry.Data["request-id"]) + if len(response.Warnings) > 0 { + assert.Equal(t, logrus.WarnLevel, entry.Level) + assert.Equal(t, response.Warnings, entry.Data["certificate-warnings"]) + } else { + assert.Equal(t, logrus.InfoLevel, entry.Level) + assert.NotContains(t, entry.Data, "certificate-warnings") + } + assert.Empty(t, fallback.String()) + } else { + assert.Equal(t, len(response.Warnings), strings.Count(fallback.String(), "WARNING:")) + } + } + }) + } + t.Run("reject has no successful warning", func(t *testing.T) { + f := newWarningAuthority(t, apiv1.IssuerValidityLimitReject) + csr, err := x509util.CreateCertificateRequest("warning.example", []string{"warning.example"}, f.tokenKey) + require.NoError(t, err) + logger, hook := logtest.NewNullLogger() + request := f.request(t, "/sign", SignRequest{CsrPEM: NewCertificateRequest(csr), OTT: f.token(t, "/sign", nil), NotAfter: NewTimeDuration(f.now.Add(72 * time.Hour))}, nil) + w := httptest.NewRecorder() + logging.NewLoggerHandler("test", &logging.Logger{Logger: logger}, http.HandlerFunc(Sign)).ServeHTTP(w, request) + require.Equal(t, http.StatusForbidden, w.Code, w.Body.String()) + var response map[string]json.RawMessage + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + assert.NotContains(t, response, "warnings") + require.Len(t, hook.AllEntries(), 1) + assert.NotContains(t, hook.LastEntry().Data, "certificate-warnings") + }) +} + +func TestCertificateWarnings_RequestSeparation(t *testing.T) { + f := newWarningAuthority(t, "") + csr, err := x509util.CreateCertificateRequest("warning.example", []string{"warning.example"}, f.tokenKey) + require.NoError(t, err) + logger, hook := logtest.NewNullLogger() + handler := logging.NewLoggerHandler("test", &logging.Logger{Logger: logger}, http.HandlerFunc(Sign)) + requests := make([]*http.Request, 8) + responses := make([]*httptest.ResponseRecorder, len(requests)) + for i := range requests { + expiry := f.now.Add(time.Hour) + if i%2 == 0 { + expiry = f.now.Add(72 * time.Hour) + } + requests[i] = f.request(t, "/sign", SignRequest{CsrPEM: NewCertificateRequest(csr), OTT: f.token(t, "/sign", nil), NotAfter: NewTimeDuration(expiry)}, nil) + responses[i] = httptest.NewRecorder() + } + var group sync.WaitGroup + for i := range requests { + group.Go(func() { handler.ServeHTTP(responses[i], requests[i]) }) + } + group.Wait() + entries := hook.AllEntries() + require.Len(t, entries, len(requests)) + byID := make(map[string]*logrus.Entry, len(entries)) + for _, entry := range entries { + byID[entry.Data["request-id"].(string)] = entry + } + for i, response := range responses { + require.Equal(t, http.StatusCreated, response.Code, response.Body.String()) + var body SignResponse + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &body)) + id, _ := requestid.FromContext(requests[i].Context()) + entry := byID[id] + require.NotNil(t, entry) + if i%2 == 0 { + assertIssuedWarning(t, body.ServerPEM.Certificate, f.issuer, f.root, body.Warnings) + assert.Equal(t, body.Warnings, entry.Data["certificate-warnings"]) + assert.Equal(t, logrus.WarnLevel, entry.Level) + } else { + assert.Empty(t, body.Warnings) + assert.NotContains(t, entry.Data, "certificate-warnings") + assert.Equal(t, logrus.InfoLevel, entry.Level) + } + } +} + +func TestIssuerValidityWarnings_RenewAndRekey(t *testing.T) { + f := newWarningAuthority(t, "") + _, key, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + csr, err := x509util.CreateCertificateRequest("warning.example", []string{"warning.example"}, key) + require.NoError(t, err) + ctx := authority.NewContext(context.Background(), f.authority) + ctx = provisioner.NewContextWithMethod(ctx, provisioner.SignMethod) + opts, err := f.authority.Authorize(ctx, f.token(t, "/sign", nil)) + require.NoError(t, err) + oldChain, err := f.authority.SignWithContext(ctx, csr, provisioner.SignOptions{NotAfter: NewTimeDuration(f.now.Add(time.Hour))}, opts...) + require.NoError(t, err) + // Rotate to a genuinely signed shorter-lived issuer, retaining its key/name. + // The original one-hour leaf remains valid for both mTLS and token renewal. + replacement := warningCACertificate(t, f.issuer.Subject.CommonName, f.now.Add(30*time.Minute), f.root, f.issuerKey, f.rootKey, f.now) + service, err := authority.NewEmbedded( + authority.WithConfig(&config.Config{DNSNames: []string{"warning.example"}, AuthorityConfig: &config.AuthConfig{Provisioners: provisioner.List{ + &provisioner.JWK{Type: "JWK", Name: "warning", Key: &jose.JSONWebKey{KeyID: "warning-key", Key: f.tokenKey.Public()}}, + }}}), + authority.WithX509RootCerts(f.root), authority.WithX509Signer(replacement, f.issuerKey), authority.WithQuietInit(), + ) + require.NoError(t, err) + f.authority = service + for _, mode := range []string{"mtls", "token", "rekey"} { + t.Run(mode, func(t *testing.T) { + var body any = struct{}{} + path := "/renew" + peer := oldChain[0] + var handler http.Handler = http.HandlerFunc(Renew) + wantKey := key.Public() + if mode == "rekey" { + _, newKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + newCSR, err := x509util.CreateCertificateRequest("warning.example", []string{"warning.example"}, newKey) + require.NoError(t, err) + body, path, handler, wantKey = RekeyRequest{CsrPEM: NewCertificateRequest(newCSR)}, "/rekey", http.HandlerFunc(Rekey), newKey.Public() + } + r := f.request(t, path, body, peer) + if mode == "token" { + r.TLS = nil + claims := jose.Claims{ID: uuid.NewString(), Subject: peer.Subject.CommonName, Issuer: "step-ca-client/1.0", Audience: []string{"https://warning.example/renew"}, NotBefore: jose.NewNumericDate(f.now), Expiry: jose.NewNumericDate(f.now.Add(time.Minute))} + token := warningToken(t, key, new(jose.SignerOptions).WithType("JWT").WithHeader("x5cInsecure", []string{base64.StdEncoding.EncodeToString(peer.Raw), base64.StdEncoding.EncodeToString(f.issuer.Raw)}), claims) + r.Header.Set("Authorization", "Bearer "+token) + } + logger, hook := logtest.NewNullLogger() + w := httptest.NewRecorder() + logging.NewLoggerHandler("test", &logging.Logger{Logger: logger}, handler).ServeHTTP(w, r) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + var response SignResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + assertIssuedWarning(t, response.ServerPEM.Certificate, replacement, replacement, response.Warnings) + assert.Equal(t, wantKey, response.ServerPEM.PublicKey) + require.Len(t, hook.AllEntries(), 1) + assert.Equal(t, logrus.WarnLevel, hook.LastEntry().Level) + assert.Equal(t, response.Warnings, hook.LastEntry().Data["certificate-warnings"]) + }) + } +} + +func TestIssuerValidityWarnings_RenewAndRekeyRejection(t *testing.T) { + f := newWarningAuthority(t, "") + _, key, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + csr, err := x509util.CreateCertificateRequest("warning.example", []string{"warning.example"}, key) + require.NoError(t, err) + ctx := authority.NewContext(context.Background(), f.authority) + ctx = provisioner.NewContextWithMethod(ctx, provisioner.SignMethod) + opts, err := f.authority.Authorize(ctx, f.token(t, "/sign", nil)) + require.NoError(t, err) + oldChain, err := f.authority.SignWithContext(ctx, csr, provisioner.SignOptions{NotAfter: NewTimeDuration(f.now.Add(time.Hour))}, opts...) + require.NoError(t, err) + + // Rotate to a shorter-lived replacement issuer under reject mode, as in + // TestIssuerValidityWarnings_RenewAndRekey: renewal keeps the original + // one-hour duration, which now exceeds the replacement's remaining life, + // so the request must be rejected before renewal signing or persistence. + replacement := warningCACertificate(t, f.issuer.Subject.CommonName, f.now.Add(30*time.Minute), f.root, f.issuerKey, f.rootKey, f.now) + service, err := authority.NewEmbedded( + authority.WithConfig(&config.Config{DNSNames: []string{"warning.example"}, AuthorityConfig: &config.AuthConfig{Options: &apiv1.Options{IssuerValidityLimit: apiv1.IssuerValidityLimitReject}, Provisioners: provisioner.List{ + &provisioner.JWK{Type: "JWK", Name: "warning", Key: &jose.JSONWebKey{KeyID: "warning-key", Key: f.tokenKey.Public()}}, + }}}), + authority.WithX509RootCerts(f.root), authority.WithX509Signer(replacement, f.issuerKey), authority.WithQuietInit(), + ) + require.NoError(t, err) + f.authority = service + + for _, mode := range []string{"mtls", "rekey"} { + t.Run(mode, func(t *testing.T) { + var body any = struct{}{} + path := "/renew" + var handler http.Handler = http.HandlerFunc(Renew) + if mode == "rekey" { + _, newKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + newCSR, err := x509util.CreateCertificateRequest("warning.example", []string{"warning.example"}, newKey) + require.NoError(t, err) + body, path, handler = RekeyRequest{CsrPEM: NewCertificateRequest(newCSR)}, "/rekey", http.HandlerFunc(Rekey) + } + logger, hook := logtest.NewNullLogger() + r := f.request(t, path, body, oldChain[0]) + w := httptest.NewRecorder() + logging.NewLoggerHandler("test", &logging.Logger{Logger: logger}, handler).ServeHTTP(w, r) + require.Equal(t, http.StatusForbidden, w.Code, w.Body.String()) + var response struct { + Status int `json:"status"` + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + // The safe policy diagnostic carries both timestamps rather than + // a generic internal error, without any success-warning metadata. + require.Equal(t, http.StatusForbidden, response.Status) + assert.Contains(t, response.Message, "requested certificate expiration ") + assert.Contains(t, response.Message, replacement.NotAfter.UTC().Truncate(time.Second).Format(time.RFC3339)) + var payload map[string]json.RawMessage + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) + assert.NotContains(t, payload, "warnings") + require.Len(t, hook.AllEntries(), 1) + assert.NotContains(t, hook.LastEntry().Data, "certificate-warnings") + }) + } +} +func TestIssuerValidityWarnings_ServerRenewRekey(t *testing.T) { + f := newWarningAuthority(t, "") + _, key, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + csr, err := x509util.CreateCertificateRequest("warning.example", []string{"warning.example"}, key) + require.NoError(t, err) + ctx := authority.NewContext(context.Background(), f.authority) + ctx = provisioner.NewContextWithMethod(ctx, provisioner.SignMethod) + opts, err := f.authority.Authorize(ctx, f.token(t, "/sign", nil)) + require.NoError(t, err) + // A one-hour leaf: renewal keeps that duration, which exceeds the + // shorter replacement issuer below, forcing the cap over a real handshake. + oldChain, err := f.authority.SignWithContext(ctx, csr, provisioner.SignOptions{NotAfter: NewTimeDuration(f.now.Add(time.Hour))}, opts...) + require.NoError(t, err) + // Rotate to a genuinely signed shorter-lived issuer, retaining its key/name. + replacement := warningCACertificate(t, f.issuer.Subject.CommonName, f.now.Add(30*time.Minute), f.root, f.issuerKey, f.rootKey, f.now) + service, err := authority.NewEmbedded( + authority.WithConfig(&config.Config{DNSNames: []string{"warning.example"}, AuthorityConfig: &config.AuthConfig{Provisioners: provisioner.List{ + &provisioner.JWK{Type: "JWK", Name: "warning", Key: &jose.JSONWebKey{KeyID: "warning-key", Key: f.tokenKey.Public()}}, + }}}), + authority.WithX509RootCerts(f.root), authority.WithX509Signer(replacement, f.issuerKey), authority.WithQuietInit(), + ) + require.NoError(t, err) + f.authority = service + + // A real TLS server with client-certificate authentication: the peer + // certificate is presented through an actual handshake, not an injected + // r.TLS ConnectionState. + // The test server needs its own certificate with an IP SAN for + // 127.0.0.1, signed by the same issuer so the client trusts it. + serverDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(1000), Subject: pkix.Name{CommonName: "warning-server"}, + NotBefore: f.now.Add(-time.Hour), NotAfter: f.now.Add(2 * time.Hour), + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + }, f.issuer, f.issuerKey.Public(), f.issuerKey) + require.NoError(t, err) + serverLeaf, err := x509.ParseCertificate(serverDER) + require.NoError(t, err) + serverCert := tls.Certificate{Certificate: [][]byte{serverLeaf.Raw, f.issuer.Raw}, PrivateKey: f.issuerKey, Leaf: serverLeaf} + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(authority.NewContext(r.Context(), f.authority)) + if r.URL.Path == "/renew" { + Renew(w, r) + return + } + Rekey(w, r) + })) + server.TLS = &tls.Config{ + Certificates: []tls.Certificate{serverCert}, + ClientAuth: tls.RequireAnyClientCert, + MinVersion: tls.VersionTLS13, + } + server.StartTLS() + t.Cleanup(server.Close) + + clientCert := tls.Certificate{Certificate: [][]byte{oldChain[0].Raw, f.issuer.Raw}, PrivateKey: key, Leaf: oldChain[0]} + clientPool := x509.NewCertPool() + clientPool.AddCert(f.root) + clientPool.AddCert(f.issuer) + client := server.Client() + client.Transport = &http.Transport{TLSClientConfig: &tls.Config{ + RootCAs: clientPool, + Certificates: []tls.Certificate{clientCert}, + MinVersion: tls.VersionTLS13, + }} + + t.Run("renew", func(t *testing.T) { + resp, err := client.Post(server.URL+"/renew", "application/json", bytes.NewReader([]byte("{}"))) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + var body SignResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assertIssuedWarning(t, body.ServerPEM.Certificate, replacement, replacement, body.Warnings) + assert.Equal(t, key.Public(), body.ServerPEM.PublicKey, "renew preserves the key") + }) + t.Run("rekey", func(t *testing.T) { + _, newKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + newCSR, err := x509util.CreateCertificateRequest("warning.example", []string{"warning.example"}, newKey) + require.NoError(t, err) + csrBody, err := json.Marshal(RekeyRequest{CsrPEM: NewCertificateRequest(newCSR)}) + require.NoError(t, err) + resp, err := client.Post(server.URL+"/rekey", "application/json", bytes.NewReader(csrBody)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + var body SignResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assertIssuedWarning(t, body.ServerPEM.Certificate, replacement, replacement, body.Warnings) + assert.Equal(t, newKey.Public(), body.ServerPEM.PublicKey, "rekey replaces the key") + }) +} diff --git a/api/rekey.go b/api/rekey.go index 772de2173..428ef0b8f 100644 --- a/api/rekey.go +++ b/api/rekey.go @@ -44,8 +44,9 @@ func Rekey(w http.ResponseWriter, r *http.Request) { return } - a := mustAuthority(r.Context()) - certChain, err := a.Rekey(r.TLS.PeerCertificates[0], body.CsrPEM.CertificateRequest.PublicKey) + ctx, warnings := collectCertificateWarnings(r.Context()) + a := mustAuthority(ctx) + certChain, err := a.RenewContext(ctx, r.TLS.PeerCertificates[0], body.CsrPEM.CertificateRequest.PublicKey) if err != nil { render.Error(w, r, errs.Wrap(http.StatusInternalServerError, err, "cahandler.Rekey")) return @@ -62,5 +63,6 @@ func Rekey(w http.ResponseWriter, r *http.Request) { CaPEM: caPEM, CertChainPEM: certChainPEM, TLSOptions: a.GetTLSOptions(), + Warnings: *warnings, }, http.StatusCreated) } diff --git a/api/renew.go b/api/renew.go index d1cab914f..4d24ef745 100644 --- a/api/renew.go +++ b/api/renew.go @@ -19,10 +19,10 @@ const ( // Renew uses the information of certificate in the TLS connection to create a // new one. func Renew(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() + ctx, warnings := collectCertificateWarnings(r.Context()) // Get the leaf certificate from the peer or the token. - cert, token, err := getPeerCertificate(r) + cert, token, err := getPeerCertificate(r.WithContext(ctx)) if err != nil { render.Error(w, r, err) return @@ -52,6 +52,7 @@ func Renew(w http.ResponseWriter, r *http.Request) { CaPEM: caPEM, CertChainPEM: certChainPEM, TLSOptions: a.GetTLSOptions(), + Warnings: *warnings, }, http.StatusCreated) } diff --git a/api/sign.go b/api/sign.go index 56469e531..1034d471d 100644 --- a/api/sign.go +++ b/api/sign.go @@ -9,6 +9,7 @@ import ( "github.com/smallstep/certificates/api/render" "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/errs" ) @@ -39,11 +40,12 @@ func (s *SignRequest) Validate() error { // SignResponse is the response object of the certificate signature request. type SignResponse struct { - ServerPEM Certificate `json:"crt"` - CaPEM Certificate `json:"ca"` - CertChainPEM []Certificate `json:"certChain"` - TLSOptions *config.TLSOptions `json:"tlsOptions,omitempty"` - TLS *tls.ConnectionState `json:"-"` + ServerPEM Certificate `json:"crt"` + CaPEM Certificate `json:"ca"` + CertChainPEM []Certificate `json:"certChain"` + TLSOptions *config.TLSOptions `json:"tlsOptions,omitempty"` + TLS *tls.ConnectionState `json:"-"` + Warnings []apiv1.CertificateWarning `json:"warnings,omitempty"` } // Sign is an HTTP handler that reads a certificate request and an @@ -68,7 +70,7 @@ func Sign(w http.ResponseWriter, r *http.Request) { TemplateData: body.TemplateData, } - ctx := r.Context() + ctx, warnings := collectCertificateWarnings(r.Context()) a := mustAuthority(ctx) ctx = provisioner.NewContextWithMethod(ctx, provisioner.SignMethod) @@ -95,5 +97,6 @@ func Sign(w http.ResponseWriter, r *http.Request) { CaPEM: caPEM, CertChainPEM: certChainPEM, TLSOptions: a.GetTLSOptions(), + Warnings: *warnings, }, http.StatusCreated) } diff --git a/api/ssh.go b/api/ssh.go index 546668e45..68c628374 100644 --- a/api/ssh.go +++ b/api/ssh.go @@ -19,6 +19,7 @@ import ( "github.com/smallstep/certificates/authority" "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/errs" "github.com/smallstep/certificates/internal/cast" "github.com/smallstep/certificates/templates" @@ -74,9 +75,10 @@ func (s *SSHSignRequest) Validate() error { // SSHSignResponse is the response object that returns the SSH certificate. type SSHSignResponse struct { - Certificate SSHCertificate `json:"crt"` - AddUserCertificate *SSHCertificate `json:"addUserCrt,omitempty"` - IdentityCertificate []Certificate `json:"identityCrt,omitempty"` + Certificate SSHCertificate `json:"crt"` + AddUserCertificate *SSHCertificate `json:"addUserCrt,omitempty"` + IdentityCertificate []Certificate `json:"identityCrt,omitempty"` + Warnings []apiv1.CertificateWarning `json:"warnings,omitempty"` } // SSHRootsResponse represents the response object that returns the SSH user and @@ -291,7 +293,8 @@ func SSHSign(w http.ResponseWriter, r *http.Request) { TemplateData: body.TemplateData, } - ctx := provisioner.NewContextWithMethod(r.Context(), provisioner.SSHSignMethod) + ctx, warnings := collectCertificateWarnings(r.Context()) + ctx = provisioner.NewContextWithMethod(ctx, provisioner.SSHSignMethod) ctx = provisioner.NewContextWithToken(ctx, body.OTT) ctx = provisioner.NewContextWithCertType(ctx, opts.CertType) @@ -321,9 +324,9 @@ func SSHSign(w http.ResponseWriter, r *http.Request) { // Sign identity certificate if available. var identityCertificate []Certificate if cr := body.IdentityCSR.CertificateRequest; cr != nil { - ctx := authority.NewContextWithSkipTokenReuse(r.Context()) - ctx = provisioner.NewContextWithMethod(ctx, provisioner.SignIdentityMethod) - signOpts, err := a.Authorize(ctx, body.OTT) + identityCtx := authority.NewContextWithSkipTokenReuse(ctx) + identityCtx = provisioner.NewContextWithMethod(identityCtx, provisioner.SignIdentityMethod) + signOpts, err := a.Authorize(identityCtx, body.OTT) if err != nil { render.Error(w, r, errs.UnauthorizedErr(err)) return @@ -336,7 +339,7 @@ func SSHSign(w http.ResponseWriter, r *http.Request) { NotAfter: time.Unix(cast.Int64(cert.ValidBefore), 0), }) - certChain, err := a.SignWithContext(ctx, cr, provisioner.SignOptions{}, signOpts...) + certChain, err := a.SignWithContext(identityCtx, cr, provisioner.SignOptions{}, signOpts...) if err != nil { render.Error(w, r, errs.ForbiddenErr(err, "error signing identity certificate")) return @@ -349,6 +352,7 @@ func SSHSign(w http.ResponseWriter, r *http.Request) { Certificate: SSHCertificate{cert}, AddUserCertificate: addUserCertificate, IdentityCertificate: identityCertificate, + Warnings: *warnings, }, http.StatusCreated) } diff --git a/api/sshRekey.go b/api/sshRekey.go index 993d29022..d7790fe71 100644 --- a/api/sshRekey.go +++ b/api/sshRekey.go @@ -9,6 +9,7 @@ import ( "github.com/smallstep/certificates/api/read" "github.com/smallstep/certificates/api/render" "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/errs" "github.com/smallstep/certificates/internal/cast" ) @@ -33,8 +34,9 @@ func (s *SSHRekeyRequest) Validate() error { // SSHRekeyResponse is the response object that returns the SSH certificate. type SSHRekeyResponse struct { - Certificate SSHCertificate `json:"crt"` - IdentityCertificate []Certificate `json:"identityCrt,omitempty"` + Certificate SSHCertificate `json:"crt"` + IdentityCertificate []Certificate `json:"identityCrt,omitempty"` + Warnings []apiv1.CertificateWarning `json:"warnings,omitempty"` } // SSHRekey is an HTTP handler that reads an RekeySSHRequest with a one-time-token @@ -59,7 +61,8 @@ func SSHRekey(w http.ResponseWriter, r *http.Request) { return } - ctx := provisioner.NewContextWithMethod(r.Context(), provisioner.SSHRekeyMethod) + ctx, warnings := collectCertificateWarnings(r.Context()) + ctx = provisioner.NewContextWithMethod(ctx, provisioner.SSHRekeyMethod) ctx = provisioner.NewContextWithToken(ctx, body.OTT) a := mustAuthority(ctx) @@ -84,7 +87,7 @@ func SSHRekey(w http.ResponseWriter, r *http.Request) { notBefore := time.Unix(cast.Int64(oldCert.ValidAfter), 0) notAfter := time.Unix(cast.Int64(oldCert.ValidBefore), 0) - identity, err := renewIdentityCertificate(r, notBefore, notAfter) + identity, err := renewIdentityCertificate(r.WithContext(ctx), notBefore, notAfter) if err != nil { render.Error(w, r, errs.ForbiddenErr(err, "error renewing identity certificate")) return @@ -94,5 +97,6 @@ func SSHRekey(w http.ResponseWriter, r *http.Request) { render.JSONStatus(w, r, &SSHRekeyResponse{ Certificate: SSHCertificate{newCert}, IdentityCertificate: identity, + Warnings: *warnings, }, http.StatusCreated) } diff --git a/api/sshRenew.go b/api/sshRenew.go index 24d11b6f2..06c185380 100644 --- a/api/sshRenew.go +++ b/api/sshRenew.go @@ -10,6 +10,7 @@ import ( "github.com/smallstep/certificates/api/read" "github.com/smallstep/certificates/api/render" "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/errs" "github.com/smallstep/certificates/internal/cast" ) @@ -31,8 +32,9 @@ func (s *SSHRenewRequest) Validate() error { // SSHRenewResponse is the response object that returns the SSH certificate. type SSHRenewResponse struct { - Certificate SSHCertificate `json:"crt"` - IdentityCertificate []Certificate `json:"identityCrt,omitempty"` + Certificate SSHCertificate `json:"crt"` + IdentityCertificate []Certificate `json:"identityCrt,omitempty"` + Warnings []apiv1.CertificateWarning `json:"warnings,omitempty"` } // SSHRenew is an HTTP handler that reads an RenewSSHRequest with a one-time-token @@ -51,7 +53,8 @@ func SSHRenew(w http.ResponseWriter, r *http.Request) { return } - ctx := provisioner.NewContextWithMethod(r.Context(), provisioner.SSHRenewMethod) + ctx, warnings := collectCertificateWarnings(r.Context()) + ctx = provisioner.NewContextWithMethod(ctx, provisioner.SSHRenewMethod) ctx = provisioner.NewContextWithToken(ctx, body.OTT) a := mustAuthority(ctx) @@ -76,16 +79,17 @@ func SSHRenew(w http.ResponseWriter, r *http.Request) { notBefore := time.Unix(cast.Int64(oldCert.ValidAfter), 0) notAfter := time.Unix(cast.Int64(oldCert.ValidBefore), 0) - identity, err := renewIdentityCertificate(r, notBefore, notAfter) + identity, err := renewIdentityCertificate(r.WithContext(ctx), notBefore, notAfter) if err != nil { render.Error(w, r, errs.ForbiddenErr(err, "error renewing identity certificate")) return } LogSSHCertificate(w, newCert) - render.JSONStatus(w, r, &SSHSignResponse{ + render.JSONStatus(w, r, &SSHRenewResponse{ Certificate: SSHCertificate{newCert}, IdentityCertificate: identity, + Warnings: *warnings, }, http.StatusCreated) } @@ -110,7 +114,7 @@ func renewIdentityCertificate(r *http.Request, notBefore, notAfter time.Time) ([ cert.NotAfter = notAfter } - certChain, err := mustAuthority(r.Context()).Renew(cert) + certChain, err := mustAuthority(r.Context()).RenewContext(r.Context(), cert, nil) if err != nil { return nil, err } diff --git a/authority/authority.go b/authority/authority.go index b91d88bb7..9e21d61f3 100644 --- a/authority/authority.go +++ b/authority/authority.go @@ -482,6 +482,23 @@ func (a *Authority) init() error { a.rootX509Certs = append(a.rootX509Certs, crts...) } } + + // Configure local issuer limits only after the actual roots are available. + // Injected signers bypass CAS construction, so this must also run for them. + var issuerLimit casapi.IssuerValidityLimit + if options := a.config.AuthorityConfig.Options; options != nil { + issuerLimit = options.IssuerValidityLimit + } + if err := issuerLimit.Validate(); err != nil { + return err + } + if limiter, ok := a.x509CAService.(casapi.IssuerValidityLimiter); ok { + if err := limiter.SetIssuerValidityLimit(issuerLimit, a.rootX509Certs); err != nil { + return err + } + } else if issuerLimit != "" { + return errors.New("issuerValidityLimit is not supported by the configured certificate authority service; configure the issuing CA instead") + } for _, crt := range a.rootX509Certs { sum := sha256.Sum256(crt.Raw) a.certificates.Store(hex.EncodeToString(sum[:]), crt) diff --git a/authority/authority_test.go b/authority/authority_test.go index dca6cf14f..1053bb132 100644 --- a/authority/authority_test.go +++ b/authority/authority_test.go @@ -439,7 +439,10 @@ func TestNewEmbedded_Sign(t *testing.T) { csr, err := x509.ParseCertificateRequest(cr) assert.FatalError(t, err) - cert, err := a.SignWithContext(context.Background(), csr, provisioner.SignOptions{}) + cert, err := a.SignWithContext(context.Background(), csr, provisioner.SignOptions{}, &certificateDurationEnforcer{ + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), + }) assert.FatalError(t, err) assert.Equals(t, []string{"foo.bar.zar"}, cert[0].DNSNames) assert.Equals(t, crt, cert[1]) diff --git a/authority/authorize_test.go b/authority/authorize_test.go index 48033195a..cb686c64a 100644 --- a/authority/authorize_test.go +++ b/authority/authorize_test.go @@ -1446,10 +1446,25 @@ func TestAuthority_AuthorizeRenewToken(t *testing.T) { } generateX5cToken := func(a *Authority, key crypto.Signer, claims jose.Claims, opts ...provisioner.SignOption) (string, *x509.Certificate) { - chain, err := a.SignWithContext(ctx, csr, provisioner.SignOptions{}, opts...) + // Token authorization must test already-expired credentials. Generate + // those fixtures directly instead of asking live issuance to create an + // expired leaf, which issuer-validity policy deliberately forbids. + template, err := x509util.NewCertificate(csr) if err != nil { t.Fatal(err) } + leaf := template.GetCertificate() + for _, opt := range opts { + if err := opt.(provisioner.CertificateEnforcer).Enforce(leaf); err != nil { + t.Fatal(err) + } + } + issuer := getDefaultIssuer(a) + cert, err := x509util.CreateCertificate(leaf, issuer, csr.PublicKey, getDefaultSigner(a)) + if err != nil { + t.Fatal(err) + } + chain := []*x509.Certificate{cert, issuer} var x5c []string for _, c := range chain { diff --git a/authority/certificate_warning_test.go b/authority/certificate_warning_test.go new file mode 100644 index 000000000..fc779416b --- /dev/null +++ b/authority/certificate_warning_test.go @@ -0,0 +1,141 @@ +package authority + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "errors" + "log" + "math/big" + "testing" + "time" + + "github.com/smallstep/certificates/authority/provisioner" + casapi "github.com/smallstep/certificates/cas/apiv1" + "github.com/smallstep/certificates/cas/softcas" + "github.com/smallstep/certificates/db" + "github.com/stretchr/testify/require" +) + +func TestIssuerValidityWarnings(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + root, issuer, signer := issuerValidityChain(t, now.Add(time.Hour), now.Add(2*time.Hour)) + a := testAuthority(t, WithX509RootCerts(root), WithX509Signer(issuer, signer)) + var persisted *x509.Certificate + a.db = &db.MockAuthDB{Ret1: false, MStoreCertificate: func(cert *x509.Certificate) error { persisted = cert; return nil }} + var warnings []casapi.CertificateWarning + ctx := casapi.WithCertificateWarningHandler(context.Background(), func(warning casapi.CertificateWarning) { + require.NotNil(t, persisted, "warnings are published only after successful persistence") + warnings = append(warnings, warning) + }) + chain, err := a.SignWithContext(ctx, issuerValidityRequest(t), provisioner.SignOptions{}, issuerValidityDates(now.Add(3*time.Hour))) + require.NoError(t, err) + require.NoError(t, chain[0].CheckSignatureFrom(issuer)) + require.Equal(t, root.NotAfter, chain[0].NotAfter) + require.Len(t, warnings, 1) + sum := sha256.Sum256(root.Raw) + require.Equal(t, "issuer_validity_truncated", warnings[0].Code) + require.Equal(t, hex.EncodeToString(sum[:]), warnings[0].LimitingCertificate.Fingerprint) + require.Equal(t, now.Add(3*time.Hour), warnings[0].RequestedNotAfter) + require.Equal(t, root.NotAfter, warnings[0].EffectiveNotAfter) + + // A different request must not inherit the preceding warning. + warnings = nil + _, err = a.SignWithContext(ctx, issuerValidityRequest(t), provisioner.SignOptions{}, issuerValidityDates(now.Add(30*time.Minute))) + require.NoError(t, err) + require.Empty(t, warnings) + + // Renew/rekey keep their historical duration arithmetic but publish the + // fresh CAS cap on this request's context, including the replacement key. + oldKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + oldTemplate := &x509.Certificate{SerialNumber: big.NewInt(900), Subject: chain[0].Subject, DNSNames: chain[0].DNSNames, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(2 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: chain[0].ExtKeyUsage} + der, err := x509.CreateCertificate(rand.Reader, oldTemplate, issuer, oldKey.Public(), signer) + require.NoError(t, err) + old, err := x509.ParseCertificate(der) + require.NoError(t, err) + for _, rekey := range []bool{false, true} { + warnings = nil + persisted = nil + pk := old.PublicKey + if rekey { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + pk = key.Public() + } + if rekey { + chain, err = a.RenewContext(ctx, old, pk) + } else { + chain, err = a.RenewContext(ctx, old, nil) + } + require.NoError(t, err) + require.Equal(t, root.NotAfter, chain[0].NotAfter) + require.Equal(t, pk, chain[0].PublicKey) + require.NoError(t, chain[0].CheckSignatureFrom(issuer)) + require.NotNil(t, persisted, "each renewal persists before warning delivery") + require.Len(t, warnings, 1) + } + + t.Run("failed persistence emits no success warning", func(t *testing.T) { + warnings = nil + a.db = &db.MockAuthDB{MStoreCertificate: func(*x509.Certificate) error { return errors.New("storage failed") }} + chain, err := a.SignWithContext(ctx, issuerValidityRequest(t), provisioner.SignOptions{}, issuerValidityDates(now.Add(3*time.Hour))) + require.Error(t, err) + require.Nil(t, chain) + require.Empty(t, warnings) + }) + t.Run("failed renewal persistence emits no success warning", func(t *testing.T) { + warnings = nil + persisted = nil + a.db = &db.MockAuthDB{MIsRevoked: func(string) (bool, error) { return false, nil }, MStoreCertificate: func(*x509.Certificate) error { return errors.New("storage failed") }} + chain, err := a.RenewContext(ctx, old, nil) + require.Error(t, err) + require.Nil(t, chain) + require.Nil(t, persisted) + require.Empty(t, warnings) + }) + t.Run("not implemented storage retains successful warnings", func(t *testing.T) { + warnings = nil + a.db = &db.MockAuthDB{MStoreCertificate: func(cert *x509.Certificate) error { persisted = cert; return db.ErrNotImplemented }} + chain, err := a.SignWithContext(ctx, issuerValidityRequest(t), provisioner.SignOptions{}, issuerValidityDates(now.Add(3*time.Hour))) + require.NoError(t, err) + require.Equal(t, root.NotAfter, chain[0].NotAfter) + require.Len(t, warnings, 1) + }) + t.Run("rejection emits no success warning", func(t *testing.T) { + warnings = nil + require.NoError(t, a.x509CAService.(*softcas.SoftCAS).SetIssuerValidityLimit(casapi.IssuerValidityLimitReject, []*x509.Certificate{root})) + chain, err := a.SignWithContext(ctx, issuerValidityRequest(t), provisioner.SignOptions{}, issuerValidityDates(now.Add(3*time.Hour))) + require.Error(t, err) + require.Nil(t, chain) + require.Empty(t, warnings) + }) +} + +func TestIssuerValidityWarningsInternalTLS(t *testing.T) { + var output bytes.Buffer + previous := log.Writer() + log.SetOutput(&output) + t.Cleanup(func() { log.SetOutput(previous) }) + now := time.Now().UTC().Truncate(time.Second) + root, issuer, signer := issuerValidityChain(t, now.Add(time.Hour), now.Add(2*time.Hour)) + a := testAuthority(t, WithX509RootCerts(root), WithX509Signer(issuer, signer)) + cert, err := a.GetTLSCertificate() + require.NoError(t, err) + require.Equal(t, root.NotAfter, cert.Leaf.NotAfter) + require.NoError(t, cert.Leaf.CheckSignatureFrom(issuer)) + require.Contains(t, output.String(), "WARNING:") + require.Contains(t, output.String(), "issuer_validity_truncated") + root, issuer, signer = issuerValidityChain(t, now.Add(30*time.Hour), now.Add(48*time.Hour)) + a = testAuthority(t, WithX509RootCerts(root), WithX509Signer(issuer, signer)) + output.Reset() + cert, err = a.GetTLSCertificate() + require.NoError(t, err) + require.WithinDuration(t, now.Add(24*time.Hour), cert.Leaf.NotAfter, 30*time.Second) + require.Empty(t, output.String()) +} diff --git a/authority/config/issuer_validity_test.go b/authority/config/issuer_validity_test.go new file mode 100644 index 000000000..5aa863a1c --- /dev/null +++ b/authority/config/issuer_validity_test.go @@ -0,0 +1,26 @@ +package config + +import ( + "encoding/json" + "testing" + + _ "github.com/smallstep/certificates/cas" +) + +func TestIssuerValidityLimit(t *testing.T) { + for _, authority := range []string{`{}`, `{"issuerValidityLimit":""}`, `{"issuerValidityLimit":"truncate"}`, `{"issuerValidityLimit":"reject"}`, `{"issuerValidityLimit":"off"}`, `{"issuerValidityLimit":"Reject"}`} { + t.Run(authority, func(t *testing.T) { + var cfg Config + data := `{"address":"127.0.0.1:9000","dnsNames":["localhost"],"root":"root.pem","crt":"issuer.pem","key":"issuer.key","authority":` + authority + `}` + if err := json.Unmarshal([]byte(data), &cfg); err != nil { + t.Fatal(err) + } + cfg.Init() + err := cfg.Validate() + invalid := authority == `{"issuerValidityLimit":"off"}` || authority == `{"issuerValidityLimit":"Reject"}` + if (err != nil) != invalid { + t.Fatalf("configuration validation: %v", err) + } + }) + } +} diff --git a/authority/issuer_validity_test.go b/authority/issuer_validity_test.go new file mode 100644 index 000000000..dcc766be2 --- /dev/null +++ b/authority/issuer_validity_test.go @@ -0,0 +1,168 @@ +package authority + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "errors" + "math/big" + "testing" + "time" + + "github.com/smallstep/certificates/authority/config" + "github.com/smallstep/certificates/authority/provisioner" + casapi "github.com/smallstep/certificates/cas/apiv1" + "github.com/smallstep/certificates/cas/softcas" + "github.com/smallstep/certificates/db" + "github.com/smallstep/certificates/errs" + "github.com/stretchr/testify/require" + "go.step.sm/crypto/jose" + "go.step.sm/crypto/x509util" +) + +// Build the root-shorter-than-intermediate fixture directly: using SoftCAS here +// would apply the policy under test and erase the intended chain boundary. +func issuerValidityChain(t *testing.T, rootExpiry, issuerExpiry time.Time) (*x509.Certificate, *x509.Certificate, crypto.Signer) { + t.Helper() + rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + issuerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + rootTemplate := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Validity root"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: rootExpiry, IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + rootDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, rootKey.Public(), rootKey) + require.NoError(t, err) + root, err := x509.ParseCertificate(rootDER) + require.NoError(t, err) + issuerTemplate := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "Validity issuer"}, NotBefore: root.NotBefore, NotAfter: issuerExpiry, IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + issuerDER, err := x509.CreateCertificate(rand.Reader, issuerTemplate, root, issuerKey.Public(), rootKey) + require.NoError(t, err) + issuer, err := x509.ParseCertificate(issuerDER) + require.NoError(t, err) + return root, issuer, issuerKey +} + +func issuerValidityRequest(t *testing.T) *x509.CertificateRequest { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + csr, err := x509util.CreateCertificateRequest("validity.example", []string{"validity.example"}, key) + require.NoError(t, err) + return csr +} + +func issuerValidityDates(expiry time.Time) provisioner.CertificateModifierFunc { + return func(crt *x509.Certificate, opts provisioner.SignOptions) error { + crt.NotBefore = time.Now().Add(-opts.Backdate) + crt.NotAfter = expiry + return nil + } +} + +func TestIssuerValidityLimit(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + root, issuer, signer := issuerValidityChain(t, now.Add(time.Hour), now.Add(2*time.Hour)) + for _, construction := range []string{"signer", "chain", "callback", "service"} { + t.Run(construction, func(t *testing.T) { + var option Option + switch construction { + case "signer": + option = WithX509Signer(issuer, signer) + case "chain": + option = WithX509SignerChain([]*x509.Certificate{issuer}, signer) + case "callback": + option = WithX509SignerFunc(func() ([]*x509.Certificate, crypto.Signer, error) { return []*x509.Certificate{issuer}, signer, nil }) + case "service": + option = WithX509CAService(&softcas.SoftCAS{CertificateChain: []*x509.Certificate{issuer}, Signer: signer}) + } + a := testAuthority(t, WithX509RootCerts(root), option) + chain, err := a.SignWithContext(context.Background(), issuerValidityRequest(t), provisioner.SignOptions{}, issuerValidityDates(now.Add(3*time.Hour))) + require.NoError(t, err) + require.Equal(t, root.NotAfter, chain[0].NotAfter) + require.NoError(t, chain[0].CheckSignatureFrom(issuer)) + require.Equal(t, []*x509.Certificate{issuer}, chain[1:]) + }) + } + t.Run("configuration overrides injected policy", func(t *testing.T) { + svc := &softcas.SoftCAS{CertificateChain: []*x509.Certificate{issuer}, Signer: signer, IssuerValidityLimit: casapi.IssuerValidityLimitReject} + a := testAuthority(t, WithX509RootCerts(root), WithX509CAService(svc), func(a *Authority) error { + a.config.AuthorityConfig.Options = &casapi.Options{IssuerValidityLimit: casapi.IssuerValidityLimitTruncate} + return nil + }) + chain, err := a.SignWithContext(context.Background(), issuerValidityRequest(t), provisioner.SignOptions{}, issuerValidityDates(now.Add(3*time.Hour))) + require.NoError(t, err) + require.Equal(t, root.NotAfter, chain[0].NotAfter) + }) + t.Run("omitted configuration preserves injected reject", func(t *testing.T) { + svc := &softcas.SoftCAS{CertificateChain: []*x509.Certificate{issuer}, Signer: signer, IssuerValidityLimit: casapi.IssuerValidityLimitReject} + a := testAuthority(t, WithX509RootCerts(root), WithX509CAService(svc)) + stored := 0 + a.db = &db.MockAuthDB{MStoreCertificate: func(*x509.Certificate) error { stored++; return nil }} + chain, err := a.SignWithContext(context.Background(), issuerValidityRequest(t), provisioner.SignOptions{}, issuerValidityDates(now.Add(3*time.Hour))) + require.Nil(t, chain) + var policyErr *casapi.IssuerValidityError + require.ErrorAs(t, err, &policyErr) + require.Equal(t, "exceeds_limit", policyErr.Reason) + require.Equal(t, 0, stored) + var publicErr *errs.Error + require.ErrorAs(t, err, &publicErr) + require.Equal(t, 403, publicErr.StatusCode()) + wire, err := json.Marshal(publicErr) + require.NoError(t, err) + var payload struct { + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal(wire, &payload)) + require.Contains(t, payload.Message, root.NotAfter.Format(time.RFC3339)) + require.Contains(t, payload.Message, now.Add(3*time.Hour).Format(time.RFC3339)) + }) + t.Run("unsupported backend requires omitted policy", func(t *testing.T) { + for _, mode := range []casapi.IssuerValidityLimit{"", casapi.IssuerValidityLimitTruncate, casapi.IssuerValidityLimitReject, "off"} { + cfg := &config.Config{Address: "127.0.0.1:9000", DNSNames: []string{"localhost"}, AuthorityConfig: &config.AuthConfig{Options: &casapi.Options{IssuerValidityLimit: mode}}} + a, err := NewEmbedded(WithConfig(cfg), WithX509RootCerts(root), WithX509CAService(notImplementedCAS{})) + if mode == "" { + require.NoError(t, err) + require.NotNil(t, a) + } else { + require.Error(t, err) + require.Nil(t, a) + } + } + }) + t.Run("original provisioner minimum validated before cap", func(t *testing.T) { + shortRoot, shortIssuer, shortSigner := issuerValidityChain(t, now.Add(2*time.Minute), now.Add(time.Hour)) + a := testAuthority(t, WithX509RootCerts(shortRoot), WithX509Signer(shortIssuer, shortSigner)) + key, err := jose.ReadKey("testdata/secrets/step_cli_key_priv.jwk", jose.WithPassword([]byte("pass"))) + require.NoError(t, err) + for _, lifetime := range []time.Duration{10 * time.Minute, time.Minute} { + token, err := generateToken("validity.example", "step-cli", testAudiences.Sign[0], []string{"validity.example"}, time.Now(), key) + require.NoError(t, err) + ctx := provisioner.NewContextWithMethod(context.Background(), provisioner.SignMethod) + opts, err := a.Authorize(ctx, token) + require.NoError(t, err) + chain, err := a.SignWithContext(ctx, issuerValidityRequest(t), provisioner.SignOptions{NotBefore: provisioner.NewTimeDuration(now), NotAfter: provisioner.NewTimeDuration(now.Add(lifetime))}, opts...) + if lifetime == time.Minute { + require.Error(t, err) + require.Nil(t, chain) + } else { + require.NoError(t, err) + require.Equal(t, shortRoot.NotAfter, chain[0].NotAfter) + require.Less(t, chain[0].NotAfter.Sub(chain[0].NotBefore), 5*time.Minute) + require.NoError(t, chain[0].CheckSignatureFrom(shortIssuer)) + } + } + }) + t.Run("strict server TLS has no exemption", func(t *testing.T) { + svc := &softcas.SoftCAS{CertificateChain: []*x509.Certificate{issuer}, Signer: signer, IssuerValidityLimit: casapi.IssuerValidityLimitReject} + a := testAuthority(t, WithX509RootCerts(root), WithX509CAService(svc)) + cert, err := a.GetTLSCertificate() + require.Nil(t, cert) + var policyErr *casapi.IssuerValidityError + require.True(t, errors.As(err, &policyErr)) + require.Equal(t, "exceeds_limit", policyErr.Reason) + }) +} diff --git a/authority/tls.go b/authority/tls.go index 936ba612e..22a636afe 100644 --- a/authority/tls.go +++ b/authority/tls.go @@ -30,6 +30,7 @@ import ( casapi "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/db" "github.com/smallstep/certificates/errs" + "github.com/smallstep/certificates/logging" "github.com/smallstep/certificates/webhook" "github.com/smallstep/nosql/database" ) @@ -307,6 +308,10 @@ func (a *Authority) signX509(ctx context.Context, csr *x509.CertificateRequest, Provisioner: pInfo, }) if err != nil { + var policyErr *casapi.IssuerValidityError + if errors.As(err, &policyErr) { + err = errs.NewError(policyErr.StatusCode(), err, "%s", policyErr.Error()) + } return nil, prov, errs.Wrap(http.StatusInternalServerError, err, "authority.Sign; error creating certificate", opts...) } @@ -321,6 +326,9 @@ func (a *Authority) signX509(ctx context.Context, csr *x509.CertificateRequest, if err := a.storeCertificate(prov, chain); err != nil && !errors.Is(err, db.ErrNotImplemented) { return nil, prov, errs.Wrap(http.StatusInternalServerError, err, "authority.Sign; error storing certificate in db", opts...) } + for _, warning := range resp.Warnings { + logging.LogCertificateWarning(ctx, warning) + } return chain, prov, nil } @@ -482,6 +490,10 @@ func (a *Authority) renewContext(ctx context.Context, oldCert *x509.Certificate, Token: token, }) if err != nil { + var policyErr *casapi.IssuerValidityError + if errors.As(err, &policyErr) { + err = errs.NewError(policyErr.StatusCode(), err, "%s", policyErr.Error()) + } return nil, prov, errs.StatusCodeError(http.StatusInternalServerError, err, opts...) } @@ -490,6 +502,9 @@ func (a *Authority) renewContext(ctx context.Context, oldCert *x509.Certificate, if err = a.storeRenewedCertificate(oldCert, chain); err != nil && !errors.Is(err, db.ErrNotImplemented) { return nil, prov, errs.StatusCodeError(http.StatusInternalServerError, err, opts...) } + for _, warning := range resp.Warnings { + logging.LogCertificateWarning(ctx, warning) + } return chain, prov, nil } @@ -955,6 +970,10 @@ func (a *Authority) GetTLSCertificate() (*tls.Certificate, error) { IsCAServerCert: true, }) if err != nil { + var policyErr *casapi.IssuerValidityError + if errors.As(err, &policyErr) { + err = errs.NewError(policyErr.StatusCode(), err, "%s", policyErr.Error()) + } return fatal(err) } @@ -980,6 +999,9 @@ func (a *Authority) GetTLSCertificate() (*tls.Certificate, error) { } // Set leaf certificate tlsCrt.Leaf = resp.Certificate + for _, warning := range resp.Warnings { + logging.LogCertificateWarning(context.Background(), warning) + } return &tlsCrt, nil } diff --git a/authority/tls_test.go b/authority/tls_test.go index 7d525b4d2..fd565bf4a 100644 --- a/authority/tls_test.go +++ b/authority/tls_test.go @@ -122,6 +122,8 @@ func generateRootCertificate(t *testing.T) (*x509.Certificate, crypto.Signer) { require.NoError(t, err) cert := template.GetCertificate() + cert.NotBefore = time.Now().Add(-time.Hour) + cert.NotAfter = time.Now().Add(365 * 24 * time.Hour) cert, err = x509util.CreateCertificate(cert, cert, priv.Public(), priv) require.NoError(t, err) return cert, priv @@ -140,6 +142,8 @@ func generateIntermidiateCertificate(t *testing.T, issuer *x509.Certificate, sig require.NoError(t, err) cert := template.GetCertificate() + cert.NotBefore = issuer.NotBefore + cert.NotAfter = issuer.NotAfter cert, err = x509util.CreateCertificate(cert, issuer, priv.Public(), signer) require.NoError(t, err) return cert, priv @@ -1118,6 +1122,7 @@ func TestAuthority_Renew(t *testing.T) { _a := testAuthority(t) _a.x509CAService.(*softcas.SoftCAS).CertificateChain = []*x509.Certificate{intCert} _a.x509CAService.(*softcas.SoftCAS).Signer = intSigner + require.NoError(t, _a.x509CAService.(*softcas.SoftCAS).SetIssuerValidityLimit("", []*x509.Certificate{rootCert})) return &renewTest{ auth: _a, cert: cert, @@ -1333,6 +1338,7 @@ func TestAuthority_Rekey(t *testing.T) { _a := testAuthority(t) _a.x509CAService.(*softcas.SoftCAS).CertificateChain = []*x509.Certificate{intCert} _a.x509CAService.(*softcas.SoftCAS).Signer = intSigner + require.NoError(t, _a.x509CAService.(*softcas.SoftCAS).SetIssuerValidityLimit("", []*x509.Certificate{rootCert})) return &renewTest{ auth: _a, cert: cert, @@ -1911,7 +1917,10 @@ func TestAuthority_constraints(t *testing.T) { t.Fatal(err) } - _, err = auth.SignWithContext(context.Background(), csr, provisioner.SignOptions{}, templateOption) + _, err = auth.SignWithContext(context.Background(), csr, provisioner.SignOptions{}, templateOption, &certificateDurationEnforcer{ + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), + }) if (err != nil) != tt.wantErr { t.Errorf("Authority.SignWithContext() error = %v, wantErr %v", err, tt.wantErr) } diff --git a/cas/apiv1/certificate_warning.go b/cas/apiv1/certificate_warning.go new file mode 100644 index 000000000..1b495b81b --- /dev/null +++ b/cas/apiv1/certificate_warning.go @@ -0,0 +1,38 @@ +package apiv1 + +import ( + "context" + "time" +) + +// LimitingCertificate identifies the CA certificate that bounded issuance. +type LimitingCertificate struct { + Subject string `json:"subject"` + Fingerprint string `json:"fingerprint"` + NotAfter time.Time `json:"notAfter"` +} + +// CertificateWarning describes a successful issuance with reduced validity. +// RequestedNotAfter is the resolved expiry before issuer limiting, including +// templates, defaults, and renewal arithmetic. Dates use UTC second precision. +type CertificateWarning struct { + Code string `json:"code"` + Message string `json:"message"` + RequestedNotAfter time.Time `json:"requestedNotAfter"` + EffectiveNotAfter time.Time `json:"effectiveNotAfter"` + LimitingCertificate LimitingCertificate `json:"limitingCertificate"` +} + +type certificateWarningHandlerKey struct{} + +// WithCertificateWarningHandler replaces the warning handler in the context. +// Delivery is synchronous; this function neither composes handlers nor logs. +func WithCertificateWarningHandler(ctx context.Context, handler func(CertificateWarning)) context.Context { + return context.WithValue(ctx, certificateWarningHandlerKey{}, handler) +} + +// CertificateWarningHandlerFromContext returns the handler, if configured. +func CertificateWarningHandlerFromContext(ctx context.Context) func(CertificateWarning) { + handler, _ := ctx.Value(certificateWarningHandlerKey{}).(func(CertificateWarning)) + return handler +} diff --git a/cas/apiv1/issuer_validity.go b/cas/apiv1/issuer_validity.go new file mode 100644 index 000000000..182661aa8 --- /dev/null +++ b/cas/apiv1/issuer_validity.go @@ -0,0 +1,28 @@ +package apiv1 + +import ( + "fmt" + "net/http" + "time" +) + +// IssuerValidityError is a request-policy rejection, not a malformed CA path. +type IssuerValidityError struct { + RequestedNotAfter time.Time + LimitNotAfter time.Time + Reason string +} + +// Error returns the safe public policy diagnostic. +func (e *IssuerValidityError) Error() string { + limit := e.LimitNotAfter.UTC().Truncate(time.Second).Format(time.RFC3339) + if e.Reason == "no_remaining_validity" { + return fmt.Sprintf("issuing CA chain expiration %s leaves no usable certificate validity; contact the CA operator", limit) + } + return fmt.Sprintf("requested certificate expiration %s exceeds issuing CA chain expiration %s", e.RequestedNotAfter.UTC().Truncate(time.Second).Format(time.RFC3339), limit) +} + +// StatusCode returns the native API policy rejection status. +func (e *IssuerValidityError) StatusCode() int { + return http.StatusForbidden +} diff --git a/cas/apiv1/issuer_validity_test.go b/cas/apiv1/issuer_validity_test.go new file mode 100644 index 000000000..d069c87c3 --- /dev/null +++ b/cas/apiv1/issuer_validity_test.go @@ -0,0 +1,27 @@ +package apiv1 + +import ( + "encoding/json" + "testing" +) + +func TestIssuerValidityLimit(t *testing.T) { + mockRegister(t) + for _, literal := range []string{"", "truncate", "reject", "off", "TRUNCATE", " trunc ate"} { + t.Run(literal, func(t *testing.T) { + var opts Options + if err := json.Unmarshal([]byte(`{"issuerValidityLimit":`+`"`+literal+`"}`), &opts); err != nil { + t.Fatal(err) + } + err := opts.Validate() + accepted := literal == "" || literal == "truncate" || literal == "reject" + if (err == nil) != accepted { + t.Fatalf("policy %q validation: %v", literal, err) + } + }) + } + var omitted *Options + if err := omitted.Validate(); err != nil { + t.Fatal(err) + } +} diff --git a/cas/apiv1/options.go b/cas/apiv1/options.go index b8d07f2f2..23b90fc20 100644 --- a/cas/apiv1/options.go +++ b/cas/apiv1/options.go @@ -10,6 +10,25 @@ import ( "go.step.sm/crypto/kms" ) +// IssuerValidityLimit controls issuance beyond the selected issuing CA path. +// The empty value uses truncation without explicitly configuring a backend. +type IssuerValidityLimit string + +const ( + IssuerValidityLimitTruncate IssuerValidityLimit = "truncate" + IssuerValidityLimitReject IssuerValidityLimit = "reject" +) + +// Validate accepts only the default, truncate, and reject policies. +func (v IssuerValidityLimit) Validate() error { + switch v { + case "", IssuerValidityLimitTruncate, IssuerValidityLimitReject: + return nil + default: + return errors.New("issuerValidityLimit must be truncate or reject") + } +} + // Options represents the configuration options used to select and configure the // CertificateAuthorityService (CAS) to use. type Options struct { @@ -43,6 +62,13 @@ type Options struct { // used in SoftCAS and it is configured in the crt property of the ca.json. CertificateChain []*x509.Certificate `json:"-"` + // IssuerValidityLimit bounds validity to the selected issuing CA path. + IssuerValidityLimit IssuerValidityLimit `json:"issuerValidityLimit,omitempty"` + + // RootCertificates completes the signing path for SoftCAS policy checks. + // These roots are not added to the returned certificate chain. + RootCertificates []*x509.Certificate `json:"-"` + // Signer is the private key or a KMS signer for the issuer certificate. It // is used in SoftCAS and it is configured in the key property of the // ca.json. @@ -97,6 +123,9 @@ func (o *Options) Validate() error { if o == nil { typ = Type(SoftCAS) } else { + if err := o.IssuerValidityLimit.Validate(); err != nil { + return err + } typ = Type(o.Type) } // Check that the type can be loaded. diff --git a/cas/apiv1/requests.go b/cas/apiv1/requests.go index 35cf92516..ad68e8ff3 100644 --- a/cas/apiv1/requests.go +++ b/cas/apiv1/requests.go @@ -73,6 +73,7 @@ type ProvisionerInfo struct { type CreateCertificateResponse struct { Certificate *x509.Certificate CertificateChain []*x509.Certificate + Warnings []CertificateWarning } // RenewCertificateRequest is the request used to re-sign a certificate. @@ -89,6 +90,7 @@ type RenewCertificateRequest struct { type RenewCertificateResponse struct { Certificate *x509.Certificate CertificateChain []*x509.Certificate + Warnings []CertificateWarning } // RevokeCertificateRequest is the request used to revoke a certificate. @@ -155,6 +157,7 @@ type CreateCertificateAuthorityResponse struct { PublicKey crypto.PublicKey PrivateKey crypto.PrivateKey Signer crypto.Signer + Warnings []CertificateWarning } // CreateCRLRequest is the request to create a Certificate Revocation List. diff --git a/cas/apiv1/services.go b/cas/apiv1/services.go index 37c5adbaa..0e1c02c21 100644 --- a/cas/apiv1/services.go +++ b/cas/apiv1/services.go @@ -15,6 +15,13 @@ type CertificateAuthorityService interface { RevokeCertificate(req *RevokeCertificateRequest) (*RevokeCertificateResponse, error) } +// IssuerValidityLimiter configures local signing-path expiry enforcement. +// The setter is initialization-time configuration, not a runtime control. +// An empty mode preserves a policy explicitly set on the service. +type IssuerValidityLimiter interface { + SetIssuerValidityLimit(IssuerValidityLimit, []*x509.Certificate) error +} + // CertificateAuthorityCRLGenerator is an optional interface implemented by CertificateAuthorityService // that has a method to create a CRL type CertificateAuthorityCRLGenerator interface { diff --git a/cas/softcas/issuer_validity.go b/cas/softcas/issuer_validity.go new file mode 100644 index 000000000..6a89c6e13 --- /dev/null +++ b/cas/softcas/issuer_validity.go @@ -0,0 +1,160 @@ +package softcas + +import ( + "bytes" + "crypto/sha256" + "crypto/x509" + "fmt" + "sync" + "time" + + "github.com/smallstep/certificates/cas/apiv1" +) + +// Certificate identity and key material are immutable after publication. Cache +// only their path links; expiry is read afresh for every issuance. Configuration +// (including the exported SoftCAS fields) is initialization-time only. +type issuerValidityCache struct { + mu sync.Mutex + chain []*x509.Certificate + roots []*x509.Certificate + certificates []*x509.Certificate +} + +// SetIssuerValidityLimit configures the local issuer policy and copies the root +// slice. An omitted mode preserves an explicitly configured service policy. +func (c *SoftCAS) SetIssuerValidityLimit(mode apiv1.IssuerValidityLimit, roots []*x509.Certificate) error { + if mode == "" { + mode = c.IssuerValidityLimit + } + if err := mode.Validate(); err != nil { + return err + } + cache := &c.issuerValidityCache + cache.mu.Lock() + defer cache.mu.Unlock() + if mode != "" { + c.IssuerValidityLimit = mode + } + c.RootCertificates = append([]*x509.Certificate(nil), roots...) + cache.chain = nil + cache.roots = nil + cache.certificates = nil + return nil +} + +func (c *SoftCAS) limitCertificateValidity(template *x509.Certificate, chain, roots []*x509.Certificate, now time.Time) ([]apiv1.CertificateWarning, error) { + if err := c.IssuerValidityLimit.Validate(); err != nil { + return nil, err + } + certificates, err := c.resolveIssuerCertificates(chain, roots) + if err != nil { + return nil, err + } + limiting := certificates[0] + limit := limiting.NotAfter.UTC().Truncate(time.Second) + for _, cert := range certificates { + if cert.NotAfter.IsZero() { + return nil, fmt.Errorf("issuing CA certificate has no expiration") + } + expiry := cert.NotAfter.UTC().Truncate(time.Second) + if expiry.Before(limit) { + limiting, limit = cert, expiry + } + } + requested := template.NotAfter.UTC().Truncate(time.Second) + effective := requested + if limit.Before(effective) { + effective = limit + } + if !effective.After(now.UTC().Truncate(time.Second)) || !effective.After(template.NotBefore.UTC().Truncate(time.Second)) { + return nil, &apiv1.IssuerValidityError{ + RequestedNotAfter: requested, LimitNotAfter: limit, Reason: "no_remaining_validity", + } + } + if requested.After(limit) && c.IssuerValidityLimit == apiv1.IssuerValidityLimitReject { + return nil, &apiv1.IssuerValidityError{ + RequestedNotAfter: requested, LimitNotAfter: limit, Reason: "exceeds_limit", + } + } + template.NotAfter = effective + if !requested.After(limit) { + return nil, nil + } + fingerprint := sha256.Sum256(limiting.Raw) + return []apiv1.CertificateWarning{{ + Code: "issuer_validity_truncated", + Message: fmt.Sprintf("Certificate expiration was reduced from %s to %s because the issuing CA chain expires then. Contact the CA operator to renew the limiting CA certificate.", requested.Format(time.RFC3339), effective.Format(time.RFC3339)), + RequestedNotAfter: requested, + EffectiveNotAfter: effective, + LimitingCertificate: apiv1.LimitingCertificate{ + Subject: limiting.Subject.String(), Fingerprint: fmt.Sprintf("%x", fingerprint), NotAfter: limit, + }, + }}, nil +} + +func (c *SoftCAS) resolveIssuerCertificates(chain, roots []*x509.Certificate) ([]*x509.Certificate, error) { + cache := &c.issuerValidityCache + cache.mu.Lock() + defer cache.mu.Unlock() + if cache.certificates != nil && sameCertificateSequence(chain, cache.chain) && sameCertificateSequence(roots, cache.roots) { + return cache.certificates, nil + } + if len(chain) == 0 { + return nil, fmt.Errorf("issuing CA certificate chain cannot be empty") + } + for i, cert := range chain { + if cert == nil || cert.NotAfter.IsZero() { + return nil, fmt.Errorf("issuing CA certificate chain contains an invalid certificate at index %d", i) + } + } + for i := range len(chain) - 1 { + if !bytes.Equal(chain[i].RawIssuer, chain[i+1].RawSubject) { + return nil, fmt.Errorf("issuing CA certificate chain is not ordered at index %d", i) + } + if err := chain[i].CheckSignatureFrom(chain[i+1]); err != nil { + return nil, fmt.Errorf("invalid issuing CA certificate chain link at index %d: %w", i, err) + } + } + for i, root := range roots { + if root == nil || root.NotAfter.IsZero() { + return nil, fmt.Errorf("issuing CA roots contain an invalid certificate at index %d", i) + } + } + certificates := make([]*x509.Certificate, len(chain), len(chain)+len(roots)) + copy(certificates, chain) + last := chain[len(chain)-1] + includedRoot := false + for _, root := range roots { + if bytes.Equal(last.Raw, root.Raw) { + includedRoot = true + break + } + } + if len(roots) > 0 && !includedRoot { + for _, root := range roots { + if bytes.Equal(last.RawIssuer, root.RawSubject) && last.CheckSignatureFrom(root) == nil { + certificates = append(certificates, root) + } + } + if len(certificates) == len(chain) { + return nil, fmt.Errorf("issuing CA certificate chain does not match any configured root") + } + } + cache.chain = certificates[:len(chain)] + cache.roots = append([]*x509.Certificate(nil), roots...) + cache.certificates = certificates + return certificates, nil +} + +func sameCertificateSequence(a, b []*x509.Certificate) bool { + if len(a) != len(b) { + return false + } + for i, cert := range a { + if cert != b[i] { + return false + } + } + return true +} diff --git a/cas/softcas/issuer_validity_authority_test.go b/cas/softcas/issuer_validity_authority_test.go new file mode 100644 index 000000000..0f036aa9b --- /dev/null +++ b/cas/softcas/issuer_validity_authority_test.go @@ -0,0 +1,189 @@ +package softcas + +import ( + "context" + "crypto" + "crypto/x509" + "testing" + "time" + + "github.com/smallstep/certificates/cas/apiv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.step.sm/crypto/kms" + kmsapi "go.step.sm/crypto/kms/apiv1" +) + +type issuerValidityKeyManager struct { + kms.KeyManager + keys, signers int + keyError, signerError error +} + +func (m *issuerValidityKeyManager) CreateKey(req *kmsapi.CreateKeyRequest) (*kmsapi.CreateKeyResponse, error) { + m.keys++ + if m.keyError != nil { + return nil, m.keyError + } + return m.KeyManager.CreateKey(req) +} + +func (m *issuerValidityKeyManager) CreateSigner(req *kmsapi.CreateSignerRequest) (crypto.Signer, error) { + m.signers++ + if m.signerError != nil { + return nil, m.signerError + } + return m.KeyManager.CreateSigner(req) +} + +func issuerValidityKMS(t *testing.T) *issuerValidityKeyManager { + t.Helper() + manager, err := kms.New(context.Background(), kmsapi.Options{Type: kmsapi.DefaultKMS}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, manager.Close()) }) + return &issuerValidityKeyManager{KeyManager: manager} +} + +func issuerValidityCATemplate() *x509.Certificate { + template := *testIntermediateTemplate + template.NotBefore, template.NotAfter = time.Time{}, time.Time{} + template.SignatureAlgorithm = 0 + return &template +} + +func TestIssuerValidityLimitCertificateAuthority(t *testing.T) { + mockNow(t) + current := testNow.UTC().Truncate(time.Second) + root, parent, parentKey := issuerValidityPath(t, current.Add(2*time.Hour), current.Add(4*time.Hour)) + unrelated, _, _ := issuerValidityPath(t, current.Add(time.Minute), current.Add(time.Minute)) + for _, lifetime := range []time.Duration{5 * time.Hour, 2 * time.Hour, time.Hour} { + t.Run(lifetime.String(), func(t *testing.T) { + manager := issuerValidityKMS(t) + parentSigner := &issuerValiditySigner{Signer: parentKey} + callbacks := 0 + service := &SoftCAS{KeyManager: manager, RootCertificates: []*x509.Certificate{unrelated}, CertificateSigner: func() ([]*x509.Certificate, crypto.Signer, error) { + callbacks++ + return nil, nil, errTest + }} + resp, err := service.CreateCertificateAuthority(&apiv1.CreateCertificateAuthorityRequest{ + Type: apiv1.IntermediateCA, Template: issuerValidityCATemplate(), Lifetime: lifetime, Backdate: time.Minute, + Parent: &apiv1.CreateCertificateAuthorityResponse{Certificate: parent, CertificateChain: []*x509.Certificate{root}, Signer: parentSigner}, + }) + require.NoError(t, err) + require.NoError(t, resp.Certificate.CheckSignatureFrom(parent)) + assert.Equal(t, current.Add(-time.Minute), resp.Certificate.NotBefore) + assert.Equal(t, resp.Signer.Public(), resp.Certificate.PublicKey) + assert.Equal(t, testIntermediateTemplate.KeyUsage, resp.Certificate.KeyUsage) + assert.True(t, resp.Certificate.IsCA) + assert.Equal(t, []*x509.Certificate{parent, root}, resp.CertificateChain) + assert.Equal(t, 0, callbacks) + assert.Equal(t, 1, manager.keys) + assert.Equal(t, 1, manager.signers) + assert.Equal(t, int64(1), parentSigner.calls.Load()) + if lifetime > 2*time.Hour { + assert.Equal(t, root.NotAfter, resp.Certificate.NotAfter) + assertIssuerValidityWarning(t, resp.Warnings, current.Add(lifetime), root.NotAfter, root) + } else { + assert.Equal(t, current.Add(lifetime), resp.Certificate.NotAfter) + assert.Nil(t, resp.Warnings) + } + }) + } + + t.Run("explicit subordinate dates", func(t *testing.T) { + service := &SoftCAS{KeyManager: issuerValidityKMS(t)} + template := issuerValidityCATemplate() + template.NotBefore = current.Add(time.Minute) + template.NotAfter = current.Add(5*time.Hour + 500*time.Millisecond) + resp, err := service.CreateCertificateAuthority(&apiv1.CreateCertificateAuthorityRequest{Type: apiv1.IntermediateCA, Template: template, Lifetime: time.Hour, Parent: &apiv1.CreateCertificateAuthorityResponse{Certificate: parent, CertificateChain: []*x509.Certificate{root}, Signer: parentKey}}) + require.NoError(t, err) + require.NoError(t, resp.Certificate.CheckSignatureFrom(parent)) + assert.Equal(t, template.NotBefore, resp.Certificate.NotBefore) + assertIssuerValidityWarning(t, resp.Warnings, current.Add(5*time.Hour), root.NotAfter, root) + }) + + t.Run("root self signing has no parent limit", func(t *testing.T) { + manager := issuerValidityKMS(t) + service := &SoftCAS{KeyManager: manager, CertificateChain: []*x509.Certificate{unrelated}, RootCertificates: []*x509.Certificate{unrelated}, IssuerValidityLimit: apiv1.IssuerValidityLimitReject} + template := *testRootTemplate + template.NotBefore, template.NotAfter = time.Time{}, time.Time{} + template.SignatureAlgorithm = 0 + // The generated KMS key, not the shared Ed25519 fixture, is the root key. + template.PublicKey = nil + resp, err := service.CreateCertificateAuthority(&apiv1.CreateCertificateAuthorityRequest{Type: apiv1.RootCA, Template: &template, Lifetime: 5 * time.Hour}) + require.NoError(t, err) + require.NoError(t, resp.Certificate.CheckSignatureFrom(resp.Certificate)) + assert.Equal(t, current.Add(5*time.Hour), resp.Certificate.NotAfter) + assert.Nil(t, resp.CertificateChain) + assert.Nil(t, resp.Warnings) + assert.Equal(t, 1, manager.keys) + assert.Equal(t, 1, manager.signers) + }) +} + +func TestIssuerValidityLimitCertificateAuthorityRejectBeforeKey(t *testing.T) { + mockNow(t) + current := testNow.UTC().Truncate(time.Second) + root, parent, parentKey := issuerValidityPath(t, current.Add(2*time.Hour), current.Add(4*time.Hour)) + expiredRoot, expiredParent, expiredKey := issuerValidityPath(t, current.Add(-time.Second), current.Add(time.Hour)) + wrongRoot := issuerValidityCA(t, root.Subject.CommonName, issuerValidityKey(t), nil, nil, current.Add(-time.Hour), current.Add(time.Hour)) + cases := []struct { + name string + mode apiv1.IssuerValidityLimit + parent *x509.Certificate + chain []*x509.Certificate + key crypto.Signer + start time.Time + reason string + }{ + {"strict overrun", apiv1.IssuerValidityLimitReject, parent, []*x509.Certificate{root}, parentKey, time.Time{}, "exceeds_limit"}, + {"expired parent path", "", expiredParent, []*x509.Certificate{expiredRoot}, expiredKey, time.Time{}, "no_remaining_validity"}, + {"future start at cap", "", parent, []*x509.Certificate{root}, parentKey, root.NotAfter, "no_remaining_validity"}, + {"nil ancestor", "", parent, []*x509.Certificate{nil}, parentKey, time.Time{}, ""}, + {"invalid adjacent signature", "", parent, []*x509.Certificate{wrongRoot}, parentKey, time.Time{}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + manager := issuerValidityKMS(t) + signer := &issuerValiditySigner{Signer: tc.key} + service := &SoftCAS{KeyManager: manager, IssuerValidityLimit: tc.mode} + template := issuerValidityCATemplate() + template.NotBefore = tc.start + resp, err := service.CreateCertificateAuthority(&apiv1.CreateCertificateAuthorityRequest{Type: apiv1.IntermediateCA, Template: template, Lifetime: 5 * time.Hour, Backdate: time.Minute, Parent: &apiv1.CreateCertificateAuthorityResponse{Certificate: tc.parent, CertificateChain: tc.chain, Signer: signer}}) + require.Error(t, err) + assert.Nil(t, resp) + assert.Equal(t, 0, manager.keys) + assert.Equal(t, 0, manager.signers) + assert.Equal(t, int64(0), signer.calls.Load()) + if tc.reason != "" { + var policy *apiv1.IssuerValidityError + require.ErrorAs(t, err, &policy) + assert.Equal(t, tc.reason, policy.Reason) + } + }) + } +} + +func TestIssuerValidityWarningsCertificateAuthorityFailure(t *testing.T) { + mockNow(t) + current := testNow.UTC().Truncate(time.Second) + root, parent, parentKey := issuerValidityPath(t, current.Add(2*time.Hour), current.Add(4*time.Hour)) + for _, stage := range []string{"key", "signer", "signature"} { + t.Run(stage, func(t *testing.T) { + manager := issuerValidityKMS(t) + signer := &issuerValiditySigner{Signer: parentKey} + switch stage { + case "key": + manager.keyError = errTest + case "signer": + manager.signerError = errTest + case "signature": + signer.err = errTest + } + service := &SoftCAS{KeyManager: manager} + resp, err := service.CreateCertificateAuthority(&apiv1.CreateCertificateAuthorityRequest{Type: apiv1.IntermediateCA, Template: issuerValidityCATemplate(), Lifetime: 5 * time.Hour, Parent: &apiv1.CreateCertificateAuthorityResponse{Certificate: parent, CertificateChain: []*x509.Certificate{root}, Signer: signer}}) + require.Error(t, err) + assert.Nil(t, resp) + }) + } +} diff --git a/cas/softcas/issuer_validity_errors_test.go b/cas/softcas/issuer_validity_errors_test.go new file mode 100644 index 000000000..e037f482d --- /dev/null +++ b/cas/softcas/issuer_validity_errors_test.go @@ -0,0 +1,166 @@ +package softcas + +import ( + "crypto" + "crypto/x509" + "errors" + "io" + "sync/atomic" + "testing" + "time" + + "github.com/smallstep/certificates/cas/apiv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type issuerValiditySigner struct { + crypto.Signer + calls atomic.Int64 + err error +} + +func (s *issuerValiditySigner) Sign(reader io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + s.calls.Add(1) + if s.err != nil { + return nil, s.err + } + return s.Signer.Sign(reader, digest, opts) +} + +func TestIssuerValidityLimitReject(t *testing.T) { + mockNow(t) + current := testNow.UTC().Truncate(time.Second) + root, issuer, key := issuerValidityPath(t, current.Add(time.Hour), current.Add(2*time.Hour)) + for _, renew := range []bool{false, true} { + t.Run(map[bool]string{false: "create", true: "renew"}[renew], func(t *testing.T) { + signer := &issuerValiditySigner{Signer: key} + callbacks := 0 + service := &SoftCAS{RootCertificates: []*x509.Certificate{root}, IssuerValidityLimit: apiv1.IssuerValidityLimitReject, CertificateSigner: func() ([]*x509.Certificate, crypto.Signer, error) { + callbacks++ + return []*x509.Certificate{issuer}, signer, nil + }} + var err error + if renew { + resp, e := service.RenewCertificate(&apiv1.RenewCertificateRequest{Template: issuerValidityTemplate(), Lifetime: time.Hour + time.Second}) + assert.Nil(t, resp) + err = e + } else { + resp, e := service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: issuerValidityTemplate(), Lifetime: time.Hour + time.Second}) + assert.Nil(t, resp) + err = e + } + var policy *apiv1.IssuerValidityError + require.ErrorAs(t, err, &policy) + assert.Equal(t, "exceeds_limit", policy.Reason) + assert.Equal(t, current.Add(time.Hour+time.Second), policy.RequestedNotAfter) + assert.Equal(t, root.NotAfter, policy.LimitNotAfter) + assert.Equal(t, int64(0), signer.calls.Load()) + assert.Equal(t, 1, callbacks) + }) + } +} + +func TestIssuerValidityLimitInvalidPath(t *testing.T) { + mockNow(t) + current := testNow.UTC().Truncate(time.Second) + root, issuer, key := issuerValidityPath(t, current.Add(2*time.Hour), current.Add(3*time.Hour)) + wrongRoot := issuerValidityCA(t, root.Subject.CommonName, issuerValidityKey(t), nil, nil, current.Add(-time.Hour), current.Add(time.Hour)) + unrelatedRoot := issuerValidityCA(t, "Unrelated", issuerValidityKey(t), nil, nil, current.Add(-time.Hour), current.Add(time.Hour)) + cases := []struct { + name string + chain, roots []*x509.Certificate + }{ + {"empty chain", nil, nil}, + {"nil issuer", []*x509.Certificate{nil}, nil}, + {"nil ancestor", []*x509.Certificate{issuer, nil}, nil}, + {"missing issuer expiry", []*x509.Certificate{{}}, nil}, + {"missing ancestor expiry", []*x509.Certificate{issuer, {}}, nil}, + {"unordered chain", []*x509.Certificate{root, issuer}, nil}, + {"bad adjacent signature", []*x509.Certificate{issuer, wrongRoot}, nil}, + {"no matching root", []*x509.Certificate{issuer}, []*x509.Certificate{unrelatedRoot}}, + {"same subject wrong key", []*x509.Certificate{issuer}, []*x509.Certificate{wrongRoot}}, + {"nil configured root", []*x509.Certificate{issuer}, []*x509.Certificate{root, nil}}, + {"missing root expiry", []*x509.Certificate{issuer}, []*x509.Certificate{root, {}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + signer := &issuerValiditySigner{Signer: key} + service := &SoftCAS{CertificateChain: tc.chain, RootCertificates: tc.roots, Signer: signer} + resp, err := service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: issuerValidityTemplate(), Lifetime: time.Hour}) + require.Error(t, err) + var policy *apiv1.IssuerValidityError + assert.False(t, errors.As(err, &policy)) + assert.Nil(t, resp) + assert.Equal(t, int64(0), signer.calls.Load()) + }) + } +} + +func TestIssuerValidityLimitIntervals(t *testing.T) { + mockNow(t) + current := testNow.UTC().Truncate(time.Second) + cases := []struct { + name string + cap, start time.Time + lifetime time.Duration + wantErr bool + }{ + {"cap at now", current, current.Add(-time.Minute), time.Hour, true}, + {"cap before now", current.Add(-time.Second), current.Add(-time.Minute), time.Hour, true}, + {"cap between backdate and now", current.Add(-30 * time.Second), current.Add(-time.Minute), time.Hour, true}, + {"cap at future start", current.Add(time.Minute), current.Add(time.Minute), time.Hour, true}, + {"cap before future start", current.Add(time.Minute), current.Add(2 * time.Minute), time.Hour, true}, + {"future start with same encoded expiry", current.Add(time.Minute), current.Add(time.Minute + 900*time.Millisecond), time.Hour, true}, + {"requested expiry at now", current.Add(time.Hour), current.Add(-time.Minute), -time.Second, true}, + {"positive second below minimum", current.Add(time.Second), current.Add(-time.Minute), time.Hour, false}, + {"positive second after future start", current.Add(time.Minute + time.Second), current.Add(time.Minute), time.Hour, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root, issuer, key := issuerValidityPath(t, tc.cap, current.Add(2*time.Hour)) + signer := &issuerValiditySigner{Signer: key} + service := &SoftCAS{CertificateChain: []*x509.Certificate{issuer}, RootCertificates: []*x509.Certificate{root}, Signer: signer} + template := issuerValidityTemplate() + template.NotBefore = tc.start + resp, err := service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: template, Lifetime: tc.lifetime}) + if tc.wantErr { + var policy *apiv1.IssuerValidityError + require.ErrorAs(t, err, &policy) + assert.Equal(t, "no_remaining_validity", policy.Reason) + assert.Equal(t, tc.cap, policy.LimitNotAfter) + assert.Nil(t, resp) + assert.Equal(t, int64(0), signer.calls.Load()) + return + } + require.NoError(t, err) + require.NoError(t, resp.Certificate.CheckSignatureFrom(issuer)) + assert.Equal(t, tc.cap, resp.Certificate.NotAfter) + assert.Equal(t, tc.start, resp.Certificate.NotBefore) + assertIssuerValidityWarning(t, resp.Warnings, current.Add(tc.lifetime), tc.cap, root) + assert.Equal(t, int64(1), signer.calls.Load()) + }) + } +} + +func TestIssuerValidityWarningsSigningFailure(t *testing.T) { + mockNow(t) + current := testNow.UTC().Truncate(time.Second) + root, issuer, key := issuerValidityPath(t, current.Add(time.Hour), current.Add(2*time.Hour)) + for _, renew := range []bool{false, true} { + t.Run(map[bool]string{false: "create", true: "renew"}[renew], func(t *testing.T) { + signer := &issuerValiditySigner{Signer: key, err: errTest} + service := &SoftCAS{CertificateChain: []*x509.Certificate{issuer}, RootCertificates: []*x509.Certificate{root}, Signer: signer} + if renew { + resp, err := service.RenewCertificate(&apiv1.RenewCertificateRequest{Template: issuerValidityTemplate(), Lifetime: 3 * time.Hour}) + require.Error(t, err) + assert.Nil(t, resp) + } else { + resp, err := service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: issuerValidityTemplate(), Lifetime: 3 * time.Hour}) + require.Error(t, err) + assert.Nil(t, resp) + } + assert.Equal(t, int64(1), signer.calls.Load()) + }) + } +} diff --git a/cas/softcas/issuer_validity_rotation_test.go b/cas/softcas/issuer_validity_rotation_test.go new file mode 100644 index 000000000..0185ffc1c --- /dev/null +++ b/cas/softcas/issuer_validity_rotation_test.go @@ -0,0 +1,153 @@ +package softcas + +import ( + "crypto" + "crypto/x509" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/smallstep/certificates/cas/apiv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.step.sm/crypto/x509util" +) + +func TestIssuerValidityWarnings(t *testing.T) { + mockNow(t) + current := testNow.UTC().Truncate(time.Second) + root, issuer, signer := issuerValidityPath(t, current.Add(2*time.Hour), current.Add(3*time.Hour)) + oldTemplate := issuerValidityTemplate() + oldTemplate.NotBefore, oldTemplate.NotAfter = current.Add(-time.Hour), current.Add(3*time.Hour) + old, err := x509util.CreateCertificate(oldTemplate, issuer, oldTemplate.PublicKey, signer) + require.NoError(t, err) + for _, rekey := range []bool{false, true} { + t.Run(map[bool]string{false: "renew", true: "rekey"}[rekey], func(t *testing.T) { + template := *old + if rekey { + template.PublicKey = issuerValidityKey(t).Public() + } + service := &SoftCAS{CertificateChain: []*x509.Certificate{issuer}, Signer: signer, RootCertificates: []*x509.Certificate{root}} + lifetime := old.NotAfter.Sub(old.NotBefore) + resp, err := service.RenewCertificate(&apiv1.RenewCertificateRequest{Template: &template, Lifetime: lifetime, Backdate: time.Minute}) + require.NoError(t, err) + require.NoError(t, resp.Certificate.CheckSignatureFrom(issuer)) + assert.Equal(t, root.NotAfter, resp.Certificate.NotAfter) + assert.Equal(t, current.Add(-time.Minute), resp.Certificate.NotBefore) + assert.Equal(t, template.PublicKey, resp.Certificate.PublicKey) + assert.Equal(t, old.DNSNames, resp.Certificate.DNSNames) + assert.Equal(t, []*x509.Certificate{issuer}, resp.CertificateChain) + assertIssuerValidityWarning(t, resp.Warnings, current.Add(lifetime), root.NotAfter, root) + + // A following unshortened request must not inherit the prior warning. + resp, err = service.RenewCertificate(&apiv1.RenewCertificateRequest{Template: issuerValidityTemplate(), Lifetime: time.Hour}) + require.NoError(t, err) + require.NoError(t, resp.Certificate.CheckSignatureFrom(issuer)) + assert.Equal(t, current.Add(time.Hour), resp.Certificate.NotAfter) + assert.Nil(t, resp.Warnings) + }) + } +} + +func TestIssuerValidityLimitRotation(t *testing.T) { + mockNow(t) + current := testNow.UTC().Truncate(time.Second) + rootKey := issuerValidityKey(t) + root := issuerValidityCA(t, "Root", rootKey, nil, nil, current.Add(-time.Hour), current.Add(6*time.Hour)) + firstKey, secondKey := issuerValidityKey(t), issuerValidityKey(t) + first := issuerValidityCA(t, "Rollover Issuer", firstKey, root, rootKey, current.Add(-time.Hour), current.Add(2*time.Hour)) + second := issuerValidityCA(t, "Rollover Issuer", secondKey, root, rootKey, current.Add(-time.Hour), current.Add(3*time.Hour)) + chain := []*x509.Certificate{first} + signer := firstKey + calls := 0 + service := &SoftCAS{RootCertificates: []*x509.Certificate{root}, CertificateSigner: func() ([]*x509.Certificate, crypto.Signer, error) { + calls++ + return chain, signer, nil + }} + for _, snapshot := range []struct { + cert *x509.Certificate + key crypto.Signer + }{{first, firstKey}, {second, secondKey}, {first, firstKey}} { + // Reuse the callback slice to exercise copied pointer-sequence keys. + chain[0], signer = snapshot.cert, snapshot.key + resp, err := service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: issuerValidityTemplate(), Lifetime: 8 * time.Hour}) + require.NoError(t, err) + require.NoError(t, resp.Certificate.CheckSignatureFrom(snapshot.cert)) + assert.Equal(t, snapshot.cert.RawSubject, resp.Certificate.RawIssuer) + assert.Equal(t, snapshot.cert.NotAfter, resp.Certificate.NotAfter) + require.Len(t, resp.CertificateChain, 1) + assert.Same(t, snapshot.cert, resp.CertificateChain[0]) + assertIssuerValidityWarning(t, resp.Warnings, current.Add(8*time.Hour), snapshot.cert.NotAfter, snapshot.cert) + } + assert.Equal(t, 3, calls) + + // A root-only rotation must also invalidate link/root selection. + shortRoot := issuerValidityCA(t, "Root", rootKey, nil, nil, current.Add(-time.Hour), current.Add(time.Hour)) + service.RootCertificates = []*x509.Certificate{shortRoot} + resp, err := service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: issuerValidityTemplate(), Lifetime: 8 * time.Hour}) + require.NoError(t, err) + require.NoError(t, resp.Certificate.CheckSignatureFrom(first)) + assertIssuerValidityWarning(t, resp.Warnings, current.Add(8*time.Hour), shortRoot.NotAfter, shortRoot) + require.NoError(t, service.SetIssuerValidityLimit("", []*x509.Certificate{root})) + resp, err = service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: issuerValidityTemplate(), Lifetime: 8 * time.Hour}) + require.NoError(t, err) + require.NoError(t, resp.Certificate.CheckSignatureFrom(first)) + assertIssuerValidityWarning(t, resp.Warnings, current.Add(8*time.Hour), first.NotAfter, first) + assert.Equal(t, 5, calls) +} + +func TestIssuerValidityLimitConcurrentRotation(t *testing.T) { + mockNow(t) + current := testNow.UTC().Truncate(time.Second) + rootKey := issuerValidityKey(t) + root := issuerValidityCA(t, "Root", rootKey, nil, nil, current.Add(-time.Hour), current.Add(6*time.Hour)) + keys := []crypto.Signer{issuerValidityKey(t), issuerValidityKey(t)} + issuers := []*x509.Certificate{ + issuerValidityCA(t, "Issuer One", keys[0], root, rootKey, current.Add(-time.Hour), current.Add(2*time.Hour)), + issuerValidityCA(t, "Issuer Two", keys[1], root, rootKey, current.Add(-time.Hour), current.Add(3*time.Hour)), + } + var callbacks atomic.Uint64 + service := &SoftCAS{RootCertificates: []*x509.Certificate{root}, CertificateSigner: func() ([]*x509.Certificate, crypto.Signer, error) { + index := int(callbacks.Add(1)-1) % len(issuers) + return []*x509.Certificate{issuers[index]}, keys[index], nil + }} + type result struct { + resp *apiv1.CreateCertificateResponse + err error + lifetime time.Duration + } + const requests = 64 + results := make(chan result, requests) + var workers sync.WaitGroup + for i := range requests { + workers.Add(1) + go func(short bool) { + defer workers.Done() + lifetime := 4 * time.Hour + if short { + lifetime = time.Hour + } + resp, err := service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: issuerValidityTemplate(), Lifetime: lifetime}) + results <- result{resp, err, lifetime} + }(i%2 == 0) + } + workers.Wait() + close(results) + for result := range results { + require.NoError(t, result.err) + require.Len(t, result.resp.CertificateChain, 1) + issuer := result.resp.CertificateChain[0] + require.True(t, issuer == issuers[0] || issuer == issuers[1]) + require.NoError(t, result.resp.Certificate.CheckSignatureFrom(issuer)) + assert.Equal(t, issuer.RawSubject, result.resp.Certificate.RawIssuer) + if result.lifetime == time.Hour { + assert.Equal(t, current.Add(time.Hour), result.resp.Certificate.NotAfter) + assert.Nil(t, result.resp.Warnings) + } else { + assert.Equal(t, issuer.NotAfter, result.resp.Certificate.NotAfter) + assertIssuerValidityWarning(t, result.resp.Warnings, current.Add(result.lifetime), issuer.NotAfter, issuer) + } + } + assert.Equal(t, uint64(requests), callbacks.Load()) +} diff --git a/cas/softcas/issuer_validity_seconds_test.go b/cas/softcas/issuer_validity_seconds_test.go new file mode 100644 index 000000000..b9c4f8873 --- /dev/null +++ b/cas/softcas/issuer_validity_seconds_test.go @@ -0,0 +1,47 @@ +package softcas + +import ( + "crypto/x509" + "testing" + "time" + + "github.com/smallstep/certificates/cas/apiv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIssuerValidityLimitSecondPrecision(t *testing.T) { + mockNow(t) + current := testNow.UTC().Truncate(time.Second) + root, issuer, signer := issuerValidityPath(t, current.Add(time.Second), current.Add(time.Hour)) + clock := current.Add(750 * time.Millisecond).In(time.FixedZone("fixture", 3600)) + calls := 0 + now = func() time.Time { calls++; return clock } + service := &SoftCAS{CertificateChain: []*x509.Certificate{issuer}, Signer: signer, RootCertificates: []*x509.Certificate{root}} + lifetime, backdate := 1250*time.Millisecond, 500*time.Millisecond + + created, err := service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: issuerValidityTemplate(), Lifetime: lifetime, Backdate: backdate}) + require.NoError(t, err) + require.NoError(t, created.Certificate.CheckSignatureFrom(issuer)) + assert.Equal(t, current, created.Certificate.NotBefore) + assert.Equal(t, current.Add(time.Second), created.Certificate.NotAfter) + assertIssuerValidityWarning(t, created.Warnings, current.Add(2*time.Second), root.NotAfter, root) + assert.Equal(t, 1, calls) + + renewed, err := service.RenewCertificate(&apiv1.RenewCertificateRequest{Template: issuerValidityTemplate(), Lifetime: lifetime, Backdate: backdate}) + require.NoError(t, err) + require.NoError(t, renewed.Certificate.CheckSignatureFrom(issuer)) + assert.Equal(t, current, renewed.Certificate.NotBefore) + assert.Equal(t, current.Add(time.Second), renewed.Certificate.NotAfter) + assertIssuerValidityWarning(t, renewed.Warnings, current.Add(2*time.Second), root.NotAfter, root) + assert.Equal(t, 2, calls) + + service.KeyManager = issuerValidityKMS(t) + child, err := service.CreateCertificateAuthority(&apiv1.CreateCertificateAuthorityRequest{Type: apiv1.IntermediateCA, Template: issuerValidityCATemplate(), Lifetime: lifetime, Backdate: backdate, Parent: &apiv1.CreateCertificateAuthorityResponse{Certificate: issuer, CertificateChain: []*x509.Certificate{root}, Signer: signer}}) + require.NoError(t, err) + require.NoError(t, child.Certificate.CheckSignatureFrom(issuer)) + assert.Equal(t, current, child.Certificate.NotBefore) + assert.Equal(t, current.Add(time.Second), child.Certificate.NotAfter) + assertIssuerValidityWarning(t, child.Warnings, current.Add(2*time.Second), root.NotAfter, root) + assert.Equal(t, 3, calls) +} diff --git a/cas/softcas/issuer_validity_test.go b/cas/softcas/issuer_validity_test.go new file mode 100644 index 000000000..8d05920e8 --- /dev/null +++ b/cas/softcas/issuer_validity_test.go @@ -0,0 +1,234 @@ +package softcas + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "fmt" + "math/big" + "testing" + "time" + + "github.com/smallstep/certificates/cas/apiv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.step.sm/crypto/x509util" +) + +func issuerValidityKey(t *testing.T) crypto.Signer { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + return key +} + +func issuerValidityCA(t *testing.T, name string, key crypto.Signer, parent *x509.Certificate, parentKey crypto.Signer, start, expiry time.Time) *x509.Certificate { + t.Helper() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: name}, + NotBefore: start, NotAfter: expiry, + IsCA: true, BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + } + if parent == nil { + parent, parentKey = template, key + } + cert, err := x509util.CreateCertificate(template, parent, key.Public(), parentKey) + require.NoError(t, err) + return cert +} + +func issuerValidityPath(t *testing.T, rootExpiry, issuerExpiry time.Time) (*x509.Certificate, *x509.Certificate, crypto.Signer) { + t.Helper() + start := testNow.Add(-time.Hour) + rootKey, issuerKey := issuerValidityKey(t), issuerValidityKey(t) + root := issuerValidityCA(t, "Root", rootKey, nil, nil, start, rootExpiry) + issuer := issuerValidityCA(t, "Issuer", issuerKey, root, rootKey, start, issuerExpiry) + return root, issuer, issuerKey +} + +func issuerValidityTemplate() *x509.Certificate { + template := *testTemplate + template.NotBefore, template.NotAfter = time.Time{}, time.Time{} + template.SignatureAlgorithm = 0 + template.ExtraExtensions = []pkix.Extension{{Id: asn1.ObjectIdentifier{1, 2, 3, 4}, Value: []byte{5, 0}}} + return &template +} + +func assertIssuerValidityWarning(t *testing.T, warnings []apiv1.CertificateWarning, requested, effective time.Time, limiting *x509.Certificate) { + t.Helper() + require.Len(t, warnings, 1) + warning := warnings[0] + assert.Equal(t, "issuer_validity_truncated", warning.Code) + assert.Equal(t, requested.UTC().Truncate(time.Second), warning.RequestedNotAfter) + assert.Equal(t, effective.UTC().Truncate(time.Second), warning.EffectiveNotAfter) + assert.Equal(t, limiting.Subject.String(), warning.LimitingCertificate.Subject) + assert.Equal(t, effective.UTC().Truncate(time.Second), warning.LimitingCertificate.NotAfter) + fingerprint := sha256.Sum256(limiting.Raw) + assert.Equal(t, fmt.Sprintf("%x", fingerprint), warning.LimitingCertificate.Fingerprint) +} + +func TestIssuerValidityLimit(t *testing.T) { + mockNow(t) + current := testNow.UTC().Truncate(time.Second) + root, issuer, signer := issuerValidityPath(t, current.Add(2*time.Hour), current.Add(3*time.Hour)) + + t.Run("resolved dates and modes", func(t *testing.T) { + cases := []struct { + name string + mode apiv1.IssuerValidityLimit + start, requested time.Time + lifetime time.Duration + want time.Time + warn bool + }{ + {name: "omitted mode and dates", lifetime: 4 * time.Hour, want: root.NotAfter, warn: true}, + {name: "explicit truncate", mode: apiv1.IssuerValidityLimitTruncate, requested: current.Add(4 * time.Hour), lifetime: time.Hour, want: root.NotAfter, warn: true}, + {name: "earlier expiry", requested: current.Add(time.Hour), lifetime: time.Hour, want: current.Add(time.Hour)}, + {name: "equality", requested: root.NotAfter, lifetime: time.Hour, want: root.NotAfter}, + {name: "one second over", requested: root.NotAfter.Add(time.Second), lifetime: time.Hour, want: root.NotAfter, warn: true}, + {name: "same encoded second", mode: apiv1.IssuerValidityLimitReject, requested: root.NotAfter.Add(999 * time.Millisecond), lifetime: time.Hour, want: root.NotAfter}, + {name: "subsecond overrun", requested: root.NotAfter.Add(1999 * time.Millisecond), lifetime: time.Hour, want: root.NotAfter, warn: true}, + {name: "explicit start with default expiry", start: current.Add(-30 * time.Minute), lifetime: 4 * time.Hour, want: root.NotAfter, warn: true}, + {name: "strict earlier expiry", mode: apiv1.IssuerValidityLimitReject, requested: current.Add(time.Hour), lifetime: time.Hour, want: current.Add(time.Hour)}, + {name: "strict equality", mode: apiv1.IssuerValidityLimitReject, requested: root.NotAfter, lifetime: time.Hour, want: root.NotAfter}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + service := &SoftCAS{CertificateChain: []*x509.Certificate{issuer}, Signer: signer, RootCertificates: []*x509.Certificate{root}, IssuerValidityLimit: tc.mode} + template := issuerValidityTemplate() + template.NotBefore, template.NotAfter = tc.start, tc.requested + resp, err := service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: template, Lifetime: tc.lifetime, Backdate: time.Minute}) + require.NoError(t, err) + require.NoError(t, resp.Certificate.CheckSignatureFrom(issuer)) + assert.Equal(t, tc.want, resp.Certificate.NotAfter) + start := tc.start + if start.IsZero() { + start = current.Add(-time.Minute) + } + assert.Equal(t, start.UTC().Truncate(time.Second), resp.Certificate.NotBefore) + assert.Equal(t, testTemplate.DNSNames, resp.Certificate.DNSNames) + assert.Equal(t, testTemplate.PublicKey, resp.Certificate.PublicKey) + assert.Equal(t, testTemplate.KeyUsage, resp.Certificate.KeyUsage) + assert.Equal(t, testTemplate.ExtKeyUsage, resp.Certificate.ExtKeyUsage) + assert.Contains(t, resp.Certificate.Extensions, template.ExtraExtensions[0]) + assert.Equal(t, []*x509.Certificate{issuer}, resp.CertificateChain) + if tc.warn { + requested := tc.requested + if requested.IsZero() { + requested = current.Add(tc.lifetime) + } + assertIssuerValidityWarning(t, resp.Warnings, requested, tc.want, root) + } else { + assert.Nil(t, resp.Warnings) + } + }) + } + }) + + t.Run("actual ordered path and roots", func(t *testing.T) { + start := current.Add(-time.Hour) + rootKey := issuerValidityKey(t) + longRoot := issuerValidityCA(t, "Shared Root", rootKey, nil, nil, start, current.Add(5*time.Hour)) + shortRoot := issuerValidityCA(t, "Shared Root", rootKey, nil, nil, start, current.Add(2*time.Hour)) + equalRoot := issuerValidityCA(t, "Shared Root", rootKey, nil, nil, start, shortRoot.NotAfter) + wrongKey := issuerValidityCA(t, "Shared Root", issuerValidityKey(t), nil, nil, start, current.Add(time.Minute)) + unrelated := issuerValidityCA(t, "Unrelated", issuerValidityKey(t), nil, nil, start, current.Add(time.Minute)) + parentKey, leafIssuerKey := issuerValidityKey(t), issuerValidityKey(t) + parent := issuerValidityCA(t, "Parent", parentKey, longRoot, rootKey, start, current.Add(4*time.Hour)) + leafIssuer := issuerValidityCA(t, "Leaf Issuer", leafIssuerKey, parent, parentKey, start, current.Add(6*time.Hour)) + shortParent := issuerValidityCA(t, "Parent", parentKey, longRoot, rootKey, start, current.Add(time.Hour)) + shortIssuer := issuerValidityCA(t, "Issuer", signer, longRoot, rootKey, start, current.Add(time.Hour)) + tiedIssuer := issuerValidityCA(t, "Issuer", signer, longRoot, rootKey, start, shortRoot.NotAfter) + cases := []struct { + name string + chain, roots []*x509.Certificate + signer crypto.Signer + limiting *x509.Certificate + }{ + {"root earlier", []*x509.Certificate{issuer}, []*x509.Certificate{root}, signer, root}, + {"issuer earlier", []*x509.Certificate{shortIssuer}, []*x509.Certificate{longRoot}, signer, shortIssuer}, + {"extra intermediate earlier", []*x509.Certificate{leafIssuer, shortParent}, []*x509.Certificate{longRoot}, leafIssuerKey, shortParent}, + {"unrelated and wrong key roots ignored", []*x509.Certificate{leafIssuer, parent}, []*x509.Certificate{unrelated, wrongKey, longRoot}, leafIssuerKey, parent}, + {"multiple issuing roots", []*x509.Certificate{leafIssuer, parent}, []*x509.Certificate{longRoot, shortRoot}, leafIssuerKey, shortRoot}, + {"root ties follow configured order", []*x509.Certificate{leafIssuer, parent}, []*x509.Certificate{equalRoot, shortRoot}, leafIssuerKey, equalRoot}, + {"supplied certificate wins tie", []*x509.Certificate{tiedIssuer}, []*x509.Certificate{shortRoot}, signer, tiedIssuer}, + {"already included root ignores alternatives", []*x509.Certificate{leafIssuer, parent, longRoot}, []*x509.Certificate{shortRoot, longRoot}, leafIssuerKey, parent}, + {"standalone issuer path", []*x509.Certificate{leafIssuer, parent}, nil, leafIssuerKey, parent}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + service := &SoftCAS{CertificateChain: tc.chain, RootCertificates: tc.roots, Signer: tc.signer} + resp, err := service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: issuerValidityTemplate(), Lifetime: 8 * time.Hour}) + require.NoError(t, err) + require.NoError(t, resp.Certificate.CheckSignatureFrom(tc.chain[0])) + assert.Equal(t, tc.limiting.NotAfter, resp.Certificate.NotAfter) + assert.Equal(t, tc.chain, resp.CertificateChain) + assertIssuerValidityWarning(t, resp.Warnings, current.Add(8*time.Hour), tc.limiting.NotAfter, tc.limiting) + }) + } + }) + + t.Run("no new PKIX constraints", func(t *testing.T) { + rootKey, issuerKey := issuerValidityKey(t), issuerValidityKey(t) + futureRoot := issuerValidityCA(t, "Future Root", rootKey, nil, nil, current.Add(time.Hour), current.Add(3*time.Hour)) + template := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "Constrained Issuer"}, IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign, NotBefore: current.Add(time.Hour), NotAfter: current.Add(2 * time.Hour), PermittedDNSDomainsCritical: true, PermittedDNSDomains: []string{"other.example"}} + futureIssuer, err := x509util.CreateCertificate(template, futureRoot, issuerKey.Public(), rootKey) + require.NoError(t, err) + service := &SoftCAS{CertificateChain: []*x509.Certificate{futureIssuer}, Signer: issuerKey, RootCertificates: []*x509.Certificate{futureRoot}} + resp, err := service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: issuerValidityTemplate(), Lifetime: 4 * time.Hour}) + require.NoError(t, err) + require.NoError(t, resp.Certificate.CheckSignatureFrom(futureIssuer)) + assert.Equal(t, futureIssuer.NotAfter, resp.Certificate.NotAfter) + assertIssuerValidityWarning(t, resp.Warnings, current.Add(4*time.Hour), futureIssuer.NotAfter, futureIssuer) + }) +} + +func TestIssuerValidityLimitConfiguration(t *testing.T) { + mockNow(t) + current := testNow.UTC().Truncate(time.Second) + root, issuer, signer := issuerValidityPath(t, current.Add(2*time.Hour), current.Add(4*time.Hour)) + shortRoot, _, _ := issuerValidityPath(t, current.Add(time.Hour), current.Add(3*time.Hour)) + for _, mode := range []apiv1.IssuerValidityLimit{"off", "TRUNCATE", "invalid"} { + _, err := New(context.Background(), apiv1.Options{IsCreator: true, IssuerValidityLimit: mode}) + require.Error(t, err) + injected := &SoftCAS{IssuerValidityLimit: mode} + require.Error(t, injected.SetIssuerValidityLimit("", []*x509.Certificate{root})) + } + roots := []*x509.Certificate{root} + service, err := New(context.Background(), apiv1.Options{CertificateChain: []*x509.Certificate{issuer}, Signer: signer, RootCertificates: roots, IssuerValidityLimit: apiv1.IssuerValidityLimitReject}) + require.NoError(t, err) + roots[0] = shortRoot + resp, err := service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: issuerValidityTemplate(), Lifetime: time.Hour}) + require.NoError(t, err) + require.NoError(t, resp.Certificate.CheckSignatureFrom(issuer)) + assert.Equal(t, current.Add(time.Hour), resp.Certificate.NotAfter) + assert.Nil(t, resp.Warnings) + + roots[0] = root + require.NoError(t, service.SetIssuerValidityLimit("", roots)) + roots[0] = shortRoot + resp, err = service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: issuerValidityTemplate(), Lifetime: 3 * time.Hour}) + var policy *apiv1.IssuerValidityError + require.ErrorAs(t, err, &policy) + assert.Equal(t, "exceeds_limit", policy.Reason) + assert.Nil(t, resp) + + require.Error(t, service.SetIssuerValidityLimit("off", []*x509.Certificate{shortRoot})) + resp, err = service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: issuerValidityTemplate(), Lifetime: 3 * time.Hour}) + require.ErrorAs(t, err, &policy) + assert.Equal(t, root.NotAfter, policy.LimitNotAfter) + assert.Nil(t, resp) + + require.NoError(t, service.SetIssuerValidityLimit(apiv1.IssuerValidityLimitTruncate, []*x509.Certificate{root})) + resp, err = service.CreateCertificate(&apiv1.CreateCertificateRequest{Template: issuerValidityTemplate(), Lifetime: 3 * time.Hour}) + require.NoError(t, err) + require.NoError(t, resp.Certificate.CheckSignatureFrom(issuer)) + assertIssuerValidityWarning(t, resp.Warnings, current.Add(3*time.Hour), root.NotAfter, root) +} diff --git a/cas/softcas/softcas.go b/cas/softcas/softcas.go index 1e590effc..50eb7aecd 100644 --- a/cas/softcas/softcas.go +++ b/cas/softcas/softcas.go @@ -28,15 +28,22 @@ var now = time.Now // SoftCAS implements a Certificate Authority Service using Golang or KMS // crypto. This is the default CAS used in step-ca. type SoftCAS struct { - CertificateChain []*x509.Certificate - Signer crypto.Signer - CertificateSigner func() ([]*x509.Certificate, crypto.Signer, error) - KeyManager kms.KeyManager + CertificateChain []*x509.Certificate + Signer crypto.Signer + CertificateSigner func() ([]*x509.Certificate, crypto.Signer, error) + KeyManager kms.KeyManager + IssuerValidityLimit apiv1.IssuerValidityLimit + RootCertificates []*x509.Certificate + + issuerValidityCache issuerValidityCache } // New creates a new CertificateAuthorityService implementation using Golang or KMS // crypto. func New(_ context.Context, opts apiv1.Options) (*SoftCAS, error) { + if err := opts.IssuerValidityLimit.Validate(); err != nil { + return nil, err + } if !opts.IsCreator { switch { case len(opts.CertificateChain) == 0 && opts.CertificateSigner == nil: @@ -46,10 +53,12 @@ func New(_ context.Context, opts apiv1.Options) (*SoftCAS, error) { } } return &SoftCAS{ - CertificateChain: opts.CertificateChain, - Signer: opts.Signer, - CertificateSigner: opts.CertificateSigner, - KeyManager: opts.KeyManager, + CertificateChain: opts.CertificateChain, + Signer: opts.Signer, + CertificateSigner: opts.CertificateSigner, + KeyManager: opts.KeyManager, + IssuerValidityLimit: opts.IssuerValidityLimit, + RootCertificates: append([]*x509.Certificate(nil), opts.RootCertificates...), }, nil } @@ -88,6 +97,10 @@ func (c *SoftCAS) CreateCertificate(req *apiv1.CreateCertificateRequest) (*apiv1 if err != nil { return nil, err } + warnings, err := c.limitCertificateValidity(req.Template, chain, c.RootCertificates, t) + if err != nil { + return nil, err + } req.Template.Issuer = chain[0].Subject cert, err := createCertificate(req.Template, chain[0], req.Template.PublicKey, signer) @@ -98,6 +111,7 @@ func (c *SoftCAS) CreateCertificate(req *apiv1.CreateCertificateRequest) (*apiv1 return &apiv1.CreateCertificateResponse{ Certificate: cert, CertificateChain: chain, + Warnings: warnings, }, nil } @@ -118,6 +132,10 @@ func (c *SoftCAS) RenewCertificate(req *apiv1.RenewCertificateRequest) (*apiv1.R if err != nil { return nil, err } + warnings, err := c.limitCertificateValidity(req.Template, chain, c.RootCertificates, t) + if err != nil { + return nil, err + } req.Template.Issuer = chain[0].Subject cert, err := createCertificate(req.Template, chain[0], req.Template.PublicKey, signer) @@ -128,6 +146,7 @@ func (c *SoftCAS) RenewCertificate(req *apiv1.RenewCertificateRequest) (*apiv1.R return &apiv1.RenewCertificateResponse{ Certificate: cert, CertificateChain: chain, + Warnings: warnings, }, nil } @@ -174,6 +193,29 @@ func (c *SoftCAS) CreateCertificateAuthority(req *apiv1.CreateCertificateAuthori return nil, errors.New("createCertificateAuthorityRequest `parent.signer` cannot be nil") } + t := now() + if req.Template.NotBefore.IsZero() { + req.Template.NotBefore = t.Add(-1 * req.Backdate) + } + if req.Template.NotAfter.IsZero() { + req.Template.NotAfter = t.Add(req.Lifetime) + } + + // A subordinate CA's explicit parent path is independent of leaf roots. + var chain []*x509.Certificate + var warnings []apiv1.CertificateWarning + if req.Parent != nil { + chain = append(chain, req.Parent.Certificate) + chain = append(chain, req.Parent.CertificateChain...) + } + if req.Type == apiv1.IntermediateCA { + var err error + warnings, err = c.limitCertificateValidity(req.Template, chain, nil, t) + if err != nil { + return nil, err + } + } + key, err := c.createKey(req.CreateKey) if err != nil { return nil, err @@ -184,14 +226,6 @@ func (c *SoftCAS) CreateCertificateAuthority(req *apiv1.CreateCertificateAuthori return nil, err } - t := now() - if req.Template.NotBefore.IsZero() { - req.Template.NotBefore = t.Add(-1 * req.Backdate) - } - if req.Template.NotAfter.IsZero() { - req.Template.NotAfter = t.Add(req.Lifetime) - } - var cert *x509.Certificate switch req.Type { case apiv1.RootCA: @@ -208,13 +242,6 @@ func (c *SoftCAS) CreateCertificateAuthority(req *apiv1.CreateCertificateAuthori return nil, errors.Errorf("createCertificateAuthorityRequest `type=%d' is invalid or not supported", req.Type) } - // Add the parent - var chain []*x509.Certificate - if req.Parent != nil { - chain = append(chain, req.Parent.Certificate) - chain = append(chain, req.Parent.CertificateChain...) - } - return &apiv1.CreateCertificateAuthorityResponse{ Name: cert.Subject.CommonName, Certificate: cert, @@ -223,6 +250,7 @@ func (c *SoftCAS) CreateCertificateAuthority(req *apiv1.CreateCertificateAuthori PublicKey: key.PublicKey, PrivateKey: key.PrivateKey, Signer: signer, + Warnings: warnings, }, nil } diff --git a/cas/stepcas/certificate_warning_test.go b/cas/stepcas/certificate_warning_test.go new file mode 100644 index 000000000..e1cc76f21 --- /dev/null +++ b/cas/stepcas/certificate_warning_test.go @@ -0,0 +1,184 @@ +package stepcas + +import ( + "bytes" + "context" + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/hex" + "encoding/json" + "math/big" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/google/uuid" + logtest "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.step.sm/crypto/jose" + + "github.com/smallstep/certificates/api" + "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/config" + "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/ca" + "github.com/smallstep/certificates/cas/apiv1" + "github.com/smallstep/certificates/logging" +) + +func TestIssuerValidityWarnings_StepCAS(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + rootTemplate := *testRootCrt + rootTemplate.NotBefore = now.Add(-time.Hour) + rootTemplate.NotAfter = now.Add(time.Hour) + rootDER, err := x509.CreateCertificate(rand.Reader, &rootTemplate, &rootTemplate, testRootKey.Public(), testRootKey) + require.NoError(t, err) + root, err := x509.ParseCertificate(rootDER) + require.NoError(t, err) + issuerTemplate := *testIssCrt + issuerTemplate.NotBefore = now.Add(-time.Hour) + issuerTemplate.NotAfter = now.Add(2 * time.Hour) + issuerDER, err := x509.CreateCertificate(rand.Reader, &issuerTemplate, root, testIssKey.Public(), testRootKey) + require.NoError(t, err) + issuer, err := x509.ParseCertificate(issuerDER) + require.NoError(t, err) + chain := []*x509.Certificate{issuer} + provisioners := func() provisioner.List { + return provisioner.List{&provisioner.JWK{ + Type: "JWK", Name: "ra@doe.org", Key: &jose.JSONWebKey{KeyID: testKeyID, Key: testX5CKey.Public()}, + Claims: &provisioner.Claims{ + MinTLSDur: &provisioner.Duration{Duration: time.Minute}, + DefaultTLSDur: &provisioner.Duration{Duration: 2 * time.Hour}, + MaxTLSDur: &provisioner.Duration{Duration: 4 * time.Hour}, + }, + }} + } + upstream, err := authority.NewEmbedded( + authority.WithConfig(&config.Config{DNSNames: []string{"127.0.0.1"}, AuthorityConfig: &config.AuthConfig{Provisioners: provisioners()}}), + authority.WithX509RootCerts(root), + authority.WithX509SignerFunc(func() ([]*x509.Certificate, crypto.Signer, error) { return chain, testIssKey, nil }), + authority.WithQuietInit(), + ) + require.NoError(t, err) + logger, hook := logtest.NewNullLogger() + handler := logging.NewLoggerHandler("upstream", &logging.Logger{Logger: logger}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(authority.NewContext(r.Context(), upstream)) + switch r.URL.Path { + case "/sign": + api.Sign(w, r) + case "/renew": + api.Renew(w, r) + default: + http.NotFound(w, r) + } + })) + + // The transport trust certificate is unrelated to the issuing CA and expires + // earlier. StepCAS must propagate policy from the upstream, not this trust path. + _, transportKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + transportTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Unrelated RA transport trust"}, + NotBefore: now.Add(-time.Minute), NotAfter: now.Add(10 * time.Minute), + IsCA: true, BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, transportTemplate, transportTemplate, transportKey.Public(), transportKey) + require.NoError(t, err) + transportCert, err := x509.ParseCertificate(der) + require.NoError(t, err) + server := httptest.NewUnstartedServer(handler) + server.TLS = &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: transportKey}}} + server.StartTLS() + t.Cleanup(server.Close) + roots := x509.NewCertPool() + roots.AddCert(transportCert) + transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}} + t.Cleanup(transport.CloseIdleConnections) + client, err := ca.NewClient(server.URL, ca.WithTransport(transport)) + require.NoError(t, err) + upstreamURL, err := url.Parse(server.URL) + require.NoError(t, err) + iss, err := newJWKIssuer(context.Background(), upstreamURL, client, &apiv1.CertificateIssuer{Type: "jwk", Provisioner: "ra@doe.org", Key: testX5CKeyPath}) + require.NoError(t, err) + ra := &StepCAS{iss: iss, client: client, authorityID: "downstream-authority"} + template := &x509.Certificate{Subject: testCR.Subject, DNSNames: testCR.DNSNames, EmailAddresses: testCR.EmailAddresses, IPAddresses: testCR.IPAddresses, URIs: testCR.URIs} + + issued, err := ra.CreateCertificate(&apiv1.CreateCertificateRequest{CSR: testCR, Template: template, Lifetime: 2 * time.Hour}) + require.NoError(t, err) + require.NoError(t, issued.Certificate.CheckSignatureFrom(issuer)) + assert.Equal(t, root.NotAfter, issued.Certificate.NotAfter) + assert.True(t, issued.Certificate.NotAfter.After(transportCert.NotAfter)) + require.Len(t, issued.Warnings, 1) + assert.Equal(t, issued.Warnings, hook.LastEntry().Data["certificate-warnings"]) + fingerprint := sha256.Sum256(root.Raw) + assert.Equal(t, hex.EncodeToString(fingerprint[:]), issued.Warnings[0].LimitingCertificate.Fingerprint) + assert.Equal(t, "issuer_validity_truncated", issued.Warnings[0].Code) + assert.Equal(t, issued.Certificate.NotAfter, issued.Warnings[0].EffectiveNotAfter) + + // Exercise the downstream native handler as well as direct CAS consumers. + downstream, err := authority.NewEmbedded( + authority.WithConfig(&config.Config{DNSNames: []string{"downstream.example"}, AuthorityConfig: &config.AuthConfig{Provisioners: provisioners()}}), + authority.WithX509RootCerts(transportCert), authority.WithX509CAService(ra), authority.WithQuietInit(), + ) + require.NoError(t, err) + downstreamIssuer := *iss + downstreamIssuer.caURL, err = url.Parse("https://downstream.example") + require.NoError(t, err) + token, err := downstreamIssuer.SignToken(template.Subject.CommonName, append(append(append([]string{}, template.DNSNames...), template.EmailAddresses...), "127.0.0.1", "::1", template.URIs[0].String()), nil) + require.NoError(t, err) + body, err := json.Marshal(api.SignRequest{CsrPEM: api.NewCertificateRequest(testCR), OTT: token, NotAfter: api.NewTimeDuration(now.Add(2 * time.Hour))}) + require.NoError(t, err) + request := httptest.NewRequest(http.MethodPost, "https://downstream.example/sign", bytes.NewReader(body)) + request = request.WithContext(authority.NewContext(request.Context(), downstream)) + downstreamLogger, downstreamHook := logtest.NewNullLogger() + w := httptest.NewRecorder() + logging.NewLoggerHandler("downstream", &logging.Logger{Logger: downstreamLogger}, http.HandlerFunc(api.Sign)).ServeHTTP(w, request) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + var native api.SignResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &native)) + require.NoError(t, native.ServerPEM.CheckSignatureFrom(issuer)) + assert.Equal(t, root.NotAfter, native.ServerPEM.NotAfter) + assert.Equal(t, hook.LastEntry().Data["certificate-warnings"], native.Warnings) + require.Len(t, downstreamHook.AllEntries(), 1) + assert.Equal(t, native.Warnings, downstreamHook.LastEntry().Data["certificate-warnings"]) + + old, err := ra.CreateCertificate(&apiv1.CreateCertificateRequest{CSR: testCR, Template: template, Lifetime: 30 * time.Minute}) + require.NoError(t, err) + assert.Empty(t, old.Warnings) + shortTemplate := *issuer + shortTemplate.RawSubject = nil + shortTemplate.Subject = pkix.Name{CommonName: "Replacement upstream issuer"} + shortTemplate.SerialNumber = big.NewInt(2) + shortTemplate.NotAfter = now.Add(15 * time.Minute) + der, err = x509.CreateCertificate(rand.Reader, &shortTemplate, issuer, testIssKey.Public(), testIssKey) + require.NoError(t, err) + shortIssuer, err := x509.ParseCertificate(der) + require.NoError(t, err) + chain = []*x509.Certificate{shortIssuer, issuer} + claims := jose.Claims{ID: uuid.NewString(), Issuer: "step-ca-client/1.0", Subject: old.Certificate.Subject.CommonName, Audience: []string{server.URL + "/renew"}, NotBefore: jose.NewNumericDate(now), Expiry: jose.NewNumericDate(now.Add(time.Minute))} + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.EdDSA, Key: testKey}, new(jose.SignerOptions).WithType("JWT").WithHeader("x5cInsecure", []string{base64.StdEncoding.EncodeToString(old.Certificate.Raw), base64.StdEncoding.EncodeToString(issuer.Raw)})) + require.NoError(t, err) + renewToken, err := jose.Signed(signer).Claims(claims).CompactSerialize() + require.NoError(t, err) + renewed, err := ra.RenewCertificate(&apiv1.RenewCertificateRequest{Token: renewToken}) + require.NoError(t, err) + require.NoError(t, renewed.Certificate.CheckSignatureFrom(shortIssuer)) + assert.Equal(t, shortIssuer.NotAfter, renewed.Certificate.NotAfter) + assert.Equal(t, old.Certificate.PublicKey, renewed.Certificate.PublicKey) + require.Len(t, renewed.Warnings, 1) + assert.Equal(t, hook.LastEntry().Data["certificate-warnings"], renewed.Warnings) + fingerprint = sha256.Sum256(shortIssuer.Raw) + assert.Equal(t, hex.EncodeToString(fingerprint[:]), renewed.Warnings[0].LimitingCertificate.Fingerprint) +} diff --git a/cas/stepcas/stepcas.go b/cas/stepcas/stepcas.go index cab8f2031..9a93c24eb 100644 --- a/cas/stepcas/stepcas.go +++ b/cas/stepcas/stepcas.go @@ -94,15 +94,7 @@ func (s *StepCAS) CreateCertificate(req *apiv1.CreateCertificateRequest) (*apiv1 info.ProvisionerName = p.Name } - cert, chain, err := s.createCertificate(req.CSR, req.Template, req.Lifetime, info) - if err != nil { - return nil, err - } - - return &apiv1.CreateCertificateResponse{ - Certificate: cert, - CertificateChain: chain, - }, nil + return s.createCertificate(req.CSR, req.Template, req.Lifetime, info) } // RenewCertificate will always return a non-implemented error as mTLS renewals @@ -126,6 +118,7 @@ func (s *StepCAS) RenewCertificate(req *apiv1.RenewCertificateRequest) (*apiv1.R return &apiv1.RenewCertificateResponse{ Certificate: cert, CertificateChain: chain, + Warnings: resp.Warnings, }, nil } @@ -174,7 +167,7 @@ func (s *StepCAS) GetCertificateAuthority(*apiv1.GetCertificateAuthorityRequest) }, nil } -func (s *StepCAS) createCertificate(cr *x509.CertificateRequest, template *x509.Certificate, lifetime time.Duration, raInfo *raInfo) (*x509.Certificate, []*x509.Certificate, error) { +func (s *StepCAS) createCertificate(cr *x509.CertificateRequest, template *x509.Certificate, lifetime time.Duration, raInfo *raInfo) (*apiv1.CreateCertificateResponse, error) { sans := make([]string, 0, len(template.DNSNames)+len(template.EmailAddresses)+len(template.IPAddresses)+len(template.URIs)) sans = append(sans, template.DNSNames...) sans = append(sans, template.EmailAddresses...) @@ -192,7 +185,7 @@ func (s *StepCAS) createCertificate(cr *x509.CertificateRequest, template *x509. token, err := s.iss.SignToken(commonName, sans, raInfo) if err != nil { - return nil, nil, err + return nil, err } resp, err := s.client.Sign(&api.SignRequest{ @@ -201,7 +194,7 @@ func (s *StepCAS) createCertificate(cr *x509.CertificateRequest, template *x509. NotAfter: s.lifetime(lifetime), }) if err != nil { - return nil, nil, err + return nil, err } var chain []*x509.Certificate @@ -210,7 +203,11 @@ func (s *StepCAS) createCertificate(cr *x509.CertificateRequest, template *x509. chain = append(chain, c.Certificate) } - return cert, chain, nil + return &apiv1.CreateCertificateResponse{ + Certificate: cert, + CertificateChain: chain, + Warnings: resp.Warnings, + }, nil } func (s *StepCAS) lifetime(d time.Duration) api.TimeDuration { diff --git a/logging/certificate_warning.go b/logging/certificate_warning.go new file mode 100644 index 000000000..dd51a26bf --- /dev/null +++ b/logging/certificate_warning.go @@ -0,0 +1,18 @@ +package logging + +import ( + "context" + "log" + + "github.com/smallstep/certificates/cas/apiv1" +) + +// LogCertificateWarning delivers an issuance warning to the request handler, or +// logs it using the standard logger when no handler is configured. +func LogCertificateWarning(ctx context.Context, warning apiv1.CertificateWarning) { + if handler := apiv1.CertificateWarningHandlerFromContext(ctx); handler != nil { + handler(warning) + return + } + log.Printf("WARNING: %q [%q]", warning.Message, warning.Code) +} diff --git a/logging/certificate_warning_test.go b/logging/certificate_warning_test.go new file mode 100644 index 000000000..8b3d9b91f --- /dev/null +++ b/logging/certificate_warning_test.go @@ -0,0 +1,93 @@ +package logging + +import ( + "bytes" + "context" + "crypto/x509" + "crypto/x509/pkix" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/sirupsen/logrus" + logtest "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.step.sm/crypto/minica" + + "github.com/smallstep/certificates/cas/apiv1" + "github.com/smallstep/certificates/cas/softcas" +) + +func TestCertificateWarnings(t *testing.T) { + ca, err := minica.New() + require.NoError(t, err) + service, err := softcas.New(context.Background(), apiv1.Options{CertificateChain: []*x509.Certificate{ca.Intermediate}, RootCertificates: []*x509.Certificate{ca.Root}, Signer: ca.Signer}) + require.NoError(t, err) + issue := func(t *testing.T) *apiv1.CreateCertificateResponse { + t.Helper() + response, err := service.CreateCertificate(&apiv1.CreateCertificateRequest{ + Template: &x509.Certificate{Subject: pkix.Name{CommonName: "warning.example"}, PublicKey: ca.Signer.Public(), KeyUsage: x509.KeyUsageDigitalSignature}, + Lifetime: 20 * 365 * 24 * time.Hour, + }) + require.NoError(t, err) + require.NoError(t, response.Certificate.CheckSignatureFrom(ca.Intermediate)) + require.Len(t, response.Warnings, 1) + assert.Equal(t, response.Certificate.NotAfter, response.Warnings[0].EffectiveNotAfter) + return response + } + + for _, status := range []int{http.StatusCreated, http.StatusInternalServerError} { + t.Run(http.StatusText(status), func(t *testing.T) { + logger, hook := logtest.NewNullLogger() + handler := NewLoggerHandler("test", &Logger{Logger: logger}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := issue(t) + for _, warning := range response.Warnings { + LogCertificateWarning(r.Context(), warning) + } + w.WriteHeader(status) + _, err := w.Write(response.Certificate.Raw) + require.NoError(t, err) + })) + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/sign", http.NoBody)) + leaf, err := x509.ParseCertificate(w.Body.Bytes()) + require.NoError(t, err) + require.NoError(t, leaf.CheckSignatureFrom(ca.Intermediate)) + require.Len(t, hook.AllEntries(), 1) + entry := hook.LastEntry() + warnings, ok := entry.Data["certificate-warnings"].([]apiv1.CertificateWarning) + require.True(t, ok) + require.Len(t, warnings, 1) + assert.Equal(t, leaf.NotAfter, warnings[0].EffectiveNotAfter) + if status >= http.StatusInternalServerError { + assert.Equal(t, logrus.ErrorLevel, entry.Level) + } else { + assert.Equal(t, logrus.WarnLevel, entry.Level) + } + }) + } + + t.Run("fallback quotes untrusted metadata", func(t *testing.T) { + response := issue(t) + warning := response.Warnings[0] + warning.Message += "\nforged record\r\x1b[31m\u2028\u2029" + warning.Code += "\n\x00\x1b" + var output bytes.Buffer + previous := log.Writer() + log.SetOutput(&output) + t.Cleanup(func() { log.SetOutput(previous) }) + LogCertificateWarning(context.Background(), warning) + assert.Equal(t, 1, strings.Count(output.String(), "\n")) + assert.Equal(t, 1, strings.Count(output.String(), "WARNING:")) + assert.NotContains(t, output.String(), "\r") + assert.NotContains(t, output.String(), "\x1b") + assert.NotContains(t, output.String(), "\x00") + assert.NotContains(t, output.String(), "\u2028") + assert.NotContains(t, output.String(), "\u2029") + assert.Contains(t, output.String(), `\nforged record\r\x1b[31m`) + }) +} diff --git a/logging/handler.go b/logging/handler.go index d8c85df64..b2df6f9a6 100644 --- a/logging/handler.go +++ b/logging/handler.go @@ -11,6 +11,7 @@ import ( "github.com/sirupsen/logrus" + "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/internal/userid" "github.com/smallstep/certificates/middleware/requestid" ) @@ -65,7 +66,12 @@ func NewLoggerHandler(name string, logger *Logger, next http.Handler) http.Handl func (l *LoggerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { t := time.Now() rw := NewResponseLogger(w) - l.next.ServeHTTP(rw, r) + var warnings []apiv1.CertificateWarning + ctx := apiv1.WithCertificateWarningHandler(r.Context(), func(warning apiv1.CertificateWarning) { + warnings = append(warnings, warning) + rw.WithFields(map[string]any{"certificate-warnings": warnings}) + }) + l.next.ServeHTTP(rw, r.WithContext(ctx)) d := time.Since(t) l.writeEntry(rw, r, t, d) } @@ -123,12 +129,16 @@ func (l *LoggerHandler) writeEntry(w ResponseLogger, r *http.Request, t time.Tim } maps.Copy(fields, w.Fields()) + warnings, _ := fields["certificate-warnings"].([]apiv1.CertificateWarning) switch { case status < http.StatusBadRequest: - if l.options.onlyTraceHealthEndpoint && uri == "/health" { + switch { + case len(warnings) > 0: + l.logger.WithFields(fields).Warn() + case l.options.onlyTraceHealthEndpoint && uri == "/health": l.logger.WithFields(fields).Trace() - } else { + default: l.logger.WithFields(fields).Info() } case status < http.StatusInternalServerError: diff --git a/pki/certificate_warning_test.go b/pki/certificate_warning_test.go new file mode 100644 index 000000000..4613a8e75 --- /dev/null +++ b/pki/certificate_warning_test.go @@ -0,0 +1,85 @@ +package pki + +import ( + "bytes" + "context" + "crypto" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "log" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/smallstep/linkedca" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.step.sm/crypto/pemutil" + + "github.com/smallstep/certificates/cas/apiv1" + "github.com/smallstep/certificates/cas/softcas" +) + +func TestIssuerValidityWarnings(t *testing.T) { + for _, scenario := range []struct { + name string + mode apiv1.IssuerValidityLimit + rootLifetime time.Duration + wantWarning bool + wantError bool + }{ + {name: "short parent warns", rootLifetime: 36 * time.Hour, wantWarning: true}, + {name: "long parent silent", rootLifetime: 20 * 365 * 24 * time.Hour}, + {name: "reject does not report success", mode: apiv1.IssuerValidityLimitReject, rootLifetime: 36 * time.Hour, wantError: true}, + } { + t.Run(scenario.name, func(t *testing.T) { + service, err := softcas.New(context.Background(), apiv1.Options{IsCreator: true, IssuerValidityLimit: scenario.mode}) + require.NoError(t, err) + parent, err := service.CreateCertificateAuthority(&apiv1.CreateCertificateAuthorityRequest{ + Type: apiv1.RootCA, Lifetime: scenario.rootLifetime, + Template: &x509.Certificate{Subject: pkix.Name{CommonName: "PKI Warning Root"}, IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign}, + }) + require.NoError(t, err) + assert.Empty(t, parent.Warnings) + p := &PKI{Configuration: linkedca.Configuration{Files: make(map[string][]byte)}, caCreator: service, options: &options{}} + p.Intermediate = filepath.Join(t.TempDir(), "intermediate.crt") + p.IntermediateKey = filepath.Join(t.TempDir(), "intermediate.key") + var output bytes.Buffer + previous := log.Writer() + log.SetOutput(&output) + t.Cleanup(func() { log.SetOutput(previous) }) + password := []byte("temporary test password") + err = p.GenerateIntermediateCertificate("Warning", "Test", "test", parent, password) + if scenario.wantError { + var policyError *apiv1.IssuerValidityError + require.True(t, errors.As(err, &policyError)) + assert.NotContains(t, p.Files, p.Intermediate) + assert.NotContains(t, p.Files, p.IntermediateKey) + assert.Empty(t, output.String()) + return + } + require.NoError(t, err) + block, _ := pem.Decode(p.Files[p.Intermediate]) + require.NotNil(t, block) + certificate, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + require.NoError(t, certificate.CheckSignatureFrom(parent.Certificate)) + key, err := pemutil.Parse(p.Files[p.IntermediateKey], pemutil.WithPassword(password)) + require.NoError(t, err) + signer, ok := key.(crypto.Signer) + require.True(t, ok) + assert.Equal(t, signer.Public(), certificate.PublicKey) + if scenario.wantWarning { + assert.Equal(t, parent.Certificate.NotAfter, certificate.NotAfter) + assert.Equal(t, 1, strings.Count(output.String(), "WARNING:")) + assert.Contains(t, output.String(), "issuer_validity_truncated") + } else { + assert.True(t, certificate.NotAfter.Before(parent.Certificate.NotAfter)) + assert.Empty(t, output.String()) + } + }) + } +} diff --git a/pki/pki.go b/pki/pki.go index 271ac97ef..2996dbbe8 100644 --- a/pki/pki.go +++ b/pki/pki.go @@ -39,6 +39,7 @@ import ( "github.com/smallstep/certificates/cas" "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/db" + "github.com/smallstep/certificates/logging" ) // DeploymentType defines witch type of deployment a user is initializing @@ -616,9 +617,15 @@ func (p *PKI) GenerateIntermediateCertificate(name, org, resource string, parent // If a kms is used it will not have the private key if resp.PrivateKey != nil { p.Files[p.IntermediateKey], err = encodePrivateKey(resp.PrivateKey, pass) + if err != nil { + return err + } } - return err + for _, warning := range resp.Warnings { + logging.LogCertificateWarning(context.Background(), warning) + } + return nil } // CreateCertificateAuthorityResponse returns a @@ -792,7 +799,14 @@ type ConfigOption func(c *authconfig.Config) error // GenerateConfig returns the step certificates configuration. func (p *PKI) GenerateConfig(opt ...ConfigOption) (*authconfig.Config, error) { var authorityOptions *apiv1.Options - if !p.casOptions.Is(apiv1.SoftCAS) { + if p.casOptions.Is(apiv1.SoftCAS) { + if p.casOptions.IssuerValidityLimit != "" { + authorityOptions = &apiv1.Options{ + Type: apiv1.SoftCAS, + IssuerValidityLimit: p.casOptions.IssuerValidityLimit, + } + } + } else { authorityOptions = &p.casOptions } diff --git a/pki/pki_test.go b/pki/pki_test.go index f707a868a..80685427c 100644 --- a/pki/pki_test.go +++ b/pki/pki_test.go @@ -2,6 +2,7 @@ package pki import ( "context" + "encoding/json" "path/filepath" "testing" @@ -312,4 +313,47 @@ func TestPKI_GenerateConfig(t *testing.T) { } }) } + + t.Run("issuer validity policy", func(t *testing.T) { + for _, tc := range []struct { + name string + policy apiv1.IssuerValidityLimit + }{ + {name: "reject", policy: apiv1.IssuerValidityLimitReject}, + {name: "omitted"}, + } { + t.Run(tc.name, func(t *testing.T) { + pki, err := New(apiv1.Options{ + Type: apiv1.SoftCAS, + IsCreator: true, + IssuerValidityLimit: tc.policy, + }) + require.NoError(t, err) + setKeyPair(t, pki) + + cfg, err := pki.GenerateConfig() + require.NoError(t, err) + + serialized, err := json.Marshal(cfg) + require.NoError(t, err) + + var document struct { + Authority map[string]json.RawMessage `json:"authority"` + } + require.NoError(t, json.Unmarshal(serialized, &document)) + + var reloaded authconfig.Config + require.NoError(t, json.Unmarshal(serialized, &reloaded)) + if tc.policy == "" { + assert.NotContains(t, document.Authority, "issuerValidityLimit") + return + } + + require.Contains(t, document.Authority, "issuerValidityLimit") + assert.JSONEq(t, `"reject"`, string(document.Authority["issuerValidityLimit"])) + require.NotNil(t, reloaded.AuthorityConfig.Options) + assert.Equal(t, tc.policy, reloaded.AuthorityConfig.IssuerValidityLimit) + }) + } + }) } diff --git a/scep/api/api.go b/scep/api/api.go index 5ea3180d1..d4a0b0d74 100644 --- a/scep/api/api.go +++ b/scep/api/api.go @@ -20,6 +20,7 @@ import ( "github.com/smallstep/certificates/api/log" "github.com/smallstep/certificates/authority" "github.com/smallstep/certificates/authority/provisioner" + casapi "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/scep" ) @@ -413,7 +414,12 @@ func PKIOperation(ctx context.Context, req request) (Response, error) { // TODO(hs): ignore this error case? It's not critical if the notification fails; but logging it might be good _ = notifyErr } - return createFailureResponse(ctx, csr, msg, smallscep.BadRequest, "internal server error; please see the certificate authority logs for more info", fmt.Errorf("error when signing new certificate: %w", err)) + failInfoText := "internal server error; please see the certificate authority logs for more info" + var validityErr *casapi.IssuerValidityError + if errors.As(err, &validityErr) { + failInfoText = validityErr.Error() + } + return createFailureResponse(ctx, csr, msg, smallscep.BadRequest, failInfoText, fmt.Errorf("error when signing new certificate: %w", err)) } if notifyErr := auth.NotifySuccess(ctx, csr, certRep.Certificate, transactionID); notifyErr != nil { diff --git a/scep/api/issuer_validity_test.go b/scep/api/issuer_validity_test.go new file mode 100644 index 000000000..b6d69f0e5 --- /dev/null +++ b/scep/api/issuer_validity_test.go @@ -0,0 +1,327 @@ +package api + +import ( + "bytes" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/asn1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + stdlog "log" + "math/big" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/smallstep/pkcs7" + smallscep "github.com/smallstep/scep" + scepx509util "github.com/smallstep/scep/x509util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.step.sm/crypto/minica" + "go.step.sm/crypto/x509util" + + "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/config" + "github.com/smallstep/certificates/authority/provisioner" + casapi "github.com/smallstep/certificates/cas/apiv1" + "github.com/smallstep/certificates/db" + "github.com/smallstep/certificates/logging" + "github.com/smallstep/certificates/middleware/requestid" + "github.com/smallstep/certificates/scep" + "github.com/smallstep/certificates/webhook" +) + +type issuerValiditySigner struct { + crypto.Signer + calls int + fail bool +} + +func (s *issuerValiditySigner) Sign(random io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + s.calls++ + if s.fail { + return nil, errors.New("private signer unavailable") + } + return s.Signer.Sign(random, digest, opts) +} + +func issuerValidityCA(t *testing.T, now, limit time.Time) *minica.CA { + t.Helper() + ca, err := minica.New(minica.WithGetSignerFunc(func() (crypto.Signer, error) { + return rsa.GenerateKey(rand.Reader, 2048) + })) + require.NoError(t, err) + root := *ca.Root + root.NotBefore, root.NotAfter = now.Add(-time.Hour), limit + ca.Root, err = x509util.CreateCertificate(&root, &root, ca.RootSigner.Public(), ca.RootSigner) + require.NoError(t, err) + intermediate := *ca.Intermediate + intermediate.NotBefore, intermediate.NotAfter = now.Add(-time.Hour), now.Add(96*time.Hour) + ca.Intermediate, err = x509util.CreateCertificate(&intermediate, ca.Root, ca.Signer.Public(), ca.RootSigner) + require.NoError(t, err) + require.NoError(t, ca.Intermediate.CheckSignatureFrom(ca.Root)) + return ca +} + +func TestIssuerValidityLimit(t *testing.T) { + for _, tc := range []struct { + name string + mode casapi.IssuerValidityLimit + requested time.Duration + remaining time.Duration + fail bool + warning bool + reason string + fallback bool + }{ + {"default truncates", "", 72 * time.Hour, 36 * time.Hour, false, true, "", false}, + {"explicit truncate", casapi.IssuerValidityLimitTruncate, 72 * time.Hour, 36 * time.Hour, false, true, "", false}, + {"below minimum remains usable", "", 72 * time.Hour, time.Minute, false, true, "", false}, + {"without logger warns through fallback", "", 72 * time.Hour, 36 * time.Hour, false, true, "", true}, + {"equal expiry stays silent", "", 36 * time.Hour, 36 * time.Hour, false, false, "", false}, + {"earlier expiry stays silent", "", time.Hour, 36 * time.Hour, false, false, "", false}, + {"reject overrun", casapi.IssuerValidityLimitReject, 72 * time.Hour, 36 * time.Hour, false, false, "exceeds_limit", false}, + {"reject permits earlier expiry", casapi.IssuerValidityLimitReject, time.Hour, 36 * time.Hour, false, false, "", false}, + {"expired root rejects", "", 72 * time.Hour, -time.Minute, false, false, "no_remaining_validity", false}, + {"signer error stays internal", "", 72 * time.Hour, 36 * time.Hour, true, false, "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + limit, requested := now.Add(tc.remaining), now.Add(tc.requested) + ca := issuerValidityCA(t, now, limit) + signer := &issuerValiditySigner{Signer: ca.Signer, fail: tc.fail} + type notificationResult struct { + body webhook.RequestBody + err error + } + notifications := make(chan notificationResult, 2) + notifyServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body webhook.RequestBody + err := json.NewDecoder(r.Body).Decode(&body) + notifications <- notificationResult{body, err} + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"allow":true}`) + })) + t.Cleanup(notifyServer.Close) + prov := &provisioner.SCEP{ + Type: "SCEP", Name: "issuer-validity", ChallengePassword: "test-challenge", + EncryptionAlgorithmIdentifier: pkcs7.EncryptionAlgorithmAES256CBC, + Claims: &provisioner.Claims{MaxTLSDur: &provisioner.Duration{Duration: 96 * time.Hour}}, + Options: &provisioner.Options{ + X509: &provisioner.X509Options{Template: fmt.Sprintf(`{ + "subject": {{ toJson .Subject }}, "sans": {{ toJson .SANs }}, + "keyUsage": ["digitalSignature", "keyEncipherment"], + "extKeyUsage": ["serverAuth", "clientAuth"], + "notBefore": %q, "notAfter": %q + }`, now.Format(time.RFC3339), requested.Format(time.RFC3339))}, + Webhooks: []*provisioner.Webhook{{ + ID: "notify", Name: "notify", Kind: "NOTIFYING", CertType: "X509", + URL: notifyServer.URL, Secret: "dGVzdC1zZWNyZXQ=", + }}, + }, + } + a, err := authority.NewEmbedded( + authority.WithConfig(&config.Config{ + DB: &db.Config{Type: "bbolt", DataSource: filepath.Join(t.TempDir(), "ca.db")}, + AuthorityConfig: &config.AuthConfig{ + Options: &casapi.Options{IssuerValidityLimit: tc.mode}, + Provisioners: provisioner.List{prov}, + }, + }), + authority.WithX509RootCerts(ca.Root), + authority.WithX509Signer(ca.Intermediate, signer), + authority.WithFullSCEPOptions(&scep.Options{ + Roots: []*x509.Certificate{ca.Root}, Intermediates: []*x509.Certificate{ca.Intermediate}, + SignerCert: ca.Intermediate, Signer: ca.Signer, + DecrypterCert: ca.Intermediate, Decrypter: ca.Signer.(*rsa.PrivateKey), + }), + authority.WithQuietInit(), + authority.WithWebhookClient(notifyServer.Client()), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, a.Shutdown()) }) + msg, clientCert, clientKey := issuerValidityRequest(t, ca.Intermediate, now) + router := chi.NewRouter() + Route(router) + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := authority.NewContext(r.Context(), a) + ctx = scep.NewContext(ctx, a.GetSCEP()) + router.ServeHTTP(w, r.WithContext(ctx)) + }) + logger, err := logging.New("scep", json.RawMessage(`{"format":"json"}`)) + require.NoError(t, err) + var logs bytes.Buffer + logger.SetOutput(&logs) + handler := requestid.New("").Middleware(logger.Middleware(next)) + if tc.fallback { + handler = next + previous := stdlog.Writer() + stdlog.SetOutput(&logs) + t.Cleanup(func() { stdlog.SetOutput(previous) }) + } + req := httptest.NewRequest(http.MethodPost, "https://ca.local/issuer-validity?operation=PKIOperation", bytes.NewReader(msg.Raw)) + req.Header.Set("Content-Type", "application/x-pki-message") + req.Header.Set("X-Request-Id", tc.name) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Equal(t, "application/x-pki-message", w.Header().Get("Content-Type")) + reply, err := smallscep.ParsePKIMessage(w.Body.Bytes(), smallscep.WithCACerts([]*x509.Certificate{ca.Intermediate})) + require.NoError(t, err, "standard SCEP clients can verify the unextended CertRep") + assert.Equal(t, smallscep.CertRep, reply.MessageType) + assert.Equal(t, msg.TransactionID, reply.TransactionID) + assert.Equal(t, smallscep.RecipientNonce(msg.SenderNonce), reply.RecipientNonce) + signed, err := pkcs7.Parse(w.Body.Bytes()) + require.NoError(t, err) + require.NoError(t, signed.Verify()) + require.NotNil(t, signed.GetOnlySigner()) + assert.Equal(t, ca.Intermediate.Raw, signed.GetOnlySigner().Raw) + + var record map[string]json.RawMessage + if !tc.fallback { + decoder := json.NewDecoder(&logs) + require.NoError(t, decoder.Decode(&record)) + var extra map[string]json.RawMessage + require.ErrorIs(t, decoder.Decode(&extra), io.EOF, "one structured access log per issuance") + assert.JSONEq(t, fmt.Sprintf("%q", tc.name), string(record["request-id"])) + assert.JSONEq(t, `200`, string(record["status"])) + } + var notified notificationResult + select { + case notified = <-notifications: + default: + t.Fatal("SCEP success/failure notification was not delivered") + } + require.NoError(t, notified.err) + assert.Equal(t, string(msg.TransactionID), notified.body.SCEPTransactionID) + select { + case <-notifications: + t.Fatal("duplicate SCEP notification") + default: + } + failInfoTextOID := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 24, 1} + var failInfoText string + if tc.reason != "" || tc.fail { + assert.Equal(t, smallscep.FAILURE, reply.PKIStatus) + assert.Equal(t, smallscep.BadRequest, reply.FailInfo) + require.NoError(t, signed.UnmarshalSignedAttribute(failInfoTextOID, &failInfoText)) + assert.Nil(t, notified.body.X509Certificate, "failure notification must not report issued material") + assert.Empty(t, signed.Content, "failure CertRep does not contain an issued certificate") + assert.NotContains(t, record, "certificate-warnings") + assert.JSONEq(t, `"info"`, string(record["level"]), "SCEP failure envelope retains HTTP 200 logging behavior") + if tc.reason != "" { + assert.Zero(t, signer.calls, "policy rejection happens before private signing") + // Independently spell the expected policy text instead of + // re-running the production formatter, so a formatter + // regression that drops a timestamp cannot pass unnoticed. + var wantText string + switch tc.reason { + case "exceeds_limit": + wantText = fmt.Sprintf("requested certificate expiration %s exceeds issuing CA chain expiration %s", + requested.UTC().Truncate(time.Second).Format(time.RFC3339), limit.UTC().Truncate(time.Second).Format(time.RFC3339)) + case "no_remaining_validity": + wantText = fmt.Sprintf("issuing CA chain expiration %s leaves no usable certificate validity; contact the CA operator", + limit.UTC().Truncate(time.Second).Format(time.RFC3339)) + } + assert.Equal(t, wantText, failInfoText) + assert.Contains(t, notified.body.SCEPErrorDescription, wantText, "NotifyFailure is preserved") + } else { + assert.Equal(t, 1, signer.calls) + assert.Equal(t, "internal server error; please see the certificate authority logs for more info", failInfoText) + assert.Contains(t, notified.body.SCEPErrorDescription, "private signer unavailable") + assert.NotContains(t, failInfoText, "private signer unavailable") + } + return + } + + assert.Equal(t, smallscep.SUCCESS, reply.PKIStatus) + assert.Empty(t, reply.FailInfo) + assert.Error(t, signed.UnmarshalSignedAttribute(failInfoTextOID, &failInfoText)) + envelope, err := pkcs7.Parse(signed.Content) + require.NoError(t, err) + content, err := envelope.Decrypt(clientCert, clientKey) + require.NoError(t, err) + certificates, err := pkcs7.Parse(content) + require.NoError(t, err) + require.Len(t, certificates.Certificates, 1, "successful SCEP payload remains a leaf-only certificate bag") + leaf := certificates.Certificates[0] + expectedExpiry := requested + if tc.warning { + expectedExpiry = limit + } + assert.Equal(t, expectedExpiry, leaf.NotAfter) + assert.Equal(t, now, leaf.NotBefore) + assert.Equal(t, []string{"leaf.example"}, leaf.DNSNames) + assert.Equal(t, clientKey.Public(), leaf.PublicKey) + require.NoError(t, leaf.CheckSignatureFrom(ca.Intermediate)) + roots, intermediates := x509.NewCertPool(), x509.NewCertPool() + roots.AddCert(ca.Root) + intermediates.AddCert(ca.Intermediate) + _, err = leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: intermediates, DNSName: "leaf.example", CurrentTime: now}) + require.NoError(t, err) + stored, err := a.GetDatabase().GetCertificate(leaf.SerialNumber.String()) + require.NoError(t, err) + assert.Equal(t, leaf.Raw, stored.Raw) + require.NotNil(t, notified.body.X509Certificate) + assert.Equal(t, leaf.Raw, notified.body.X509Certificate.Raw) + assert.Empty(t, notified.body.SCEPErrorDescription) + switch { + case tc.fallback: + assert.Equal(t, 1, bytes.Count(logs.Bytes(), []byte("WARNING:"))) + assert.Equal(t, 1, bytes.Count(logs.Bytes(), []byte("\n"))) + assert.Contains(t, logs.String(), "issuer_validity_truncated") + assert.Contains(t, logs.String(), limit.Format(time.RFC3339)) + case tc.warning: + assert.JSONEq(t, `"warning"`, string(record["level"])) + var warnings []casapi.CertificateWarning + require.NoError(t, json.Unmarshal(record["certificate-warnings"], &warnings)) + require.Len(t, warnings, 1) + assert.Equal(t, "issuer_validity_truncated", warnings[0].Code) + assert.Equal(t, requested, warnings[0].RequestedNotAfter) + assert.Equal(t, limit, warnings[0].EffectiveNotAfter) + hash := sha256.Sum256(ca.Root.Raw) + assert.Equal(t, casapi.LimitingCertificate{Subject: ca.Root.Subject.String(), Fingerprint: hex.EncodeToString(hash[:]), NotAfter: limit}, warnings[0].LimitingCertificate) + default: + assert.JSONEq(t, `"info"`, string(record["level"])) + assert.NotContains(t, record, "certificate-warnings") + } + }) + } +} + +func issuerValidityRequest(t *testing.T, recipient *x509.Certificate, now time.Time) (*smallscep.PKIMessage, *x509.Certificate, *rsa.PrivateKey) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + csr, err := x509util.CreateCertificateRequest("leaf.example", []string{"leaf.example"}, key) + require.NoError(t, err) + der, err := scepx509util.CreateCertificateRequest(rand.Reader, &scepx509util.CertificateRequest{ + CertificateRequest: *csr, ChallengePassword: "test-challenge", + }, key) + require.NoError(t, err) + csr, err = x509.ParseCertificateRequest(der) + require.NoError(t, err) + template := &x509.Certificate{ + Subject: csr.Subject, PublicKey: key.Public(), SerialNumber: big.NewInt(1), + NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + } + cert, err := x509util.CreateCertificate(template, template, key.Public(), key) + require.NoError(t, err) + msg, err := smallscep.NewCSRRequest(csr, &smallscep.PKIMessage{ + TransactionID: "issuer-validity", MessageType: smallscep.PKCSReq, SenderNonce: []byte("test-nonce-12345"), + Recipients: []*x509.Certificate{recipient}, SignerCert: cert, SignerKey: key, + }) + require.NoError(t, err) + return msg, cert, key +}