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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
281 changes: 281 additions & 0 deletions acme/issuer_validity_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading