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