Steps to Reproduce
Reproduced on 0.24.x–0.30.6; does not reproduce on 0.22.0 (pre-#809 behavior)
Create the ca.json (no step ca init, no defaults.json — the CA has never run) and a root cert, exactly as an offline bootstrap tool would produce them.
cat > /home/step/ca.json <<'EOF'
{
"root": "/home/step/certs/root_ca.crt",
"crt": "/home/step/certs/intermediate_ca.crt",
"key": "awskms:key-id=test-key-id",
"kms": { "type": "awskms", "uri": "awskms:region=us-east-1" },
"address": ":9000",
"dnsNames": ["ca.svc.cluster.local"],
"db": { "type": "mysql", "dataSource": "user:pass@tcp(host:3306)/", "database": "smallstep" }
}
EOF
step certificate create --profile root-ca --no-password --insecure \
"Test Root" /home/step/root_ca.crt /home/step/root_ca_key
echo mypassword > /home/step/pass.txt
step ca provisioner add step-issuer --type=JWK --create \
--x509-default-dur=4368h --x509-min-dur=240h --x509-max-dur=8760h \
--password-file=/home/step/pass.txt \
--ca-config /home/step/config/ca.json
Your Environment
- OS: Alpine Step CLI container running from docker (colima) on MacOS 26
- step CLI version:
0.30.6
Expected Behavior
Since --ca-config points at an existing, valid ca.json file and there is no CA to talk to, step should edit that file locally without requiring any CA connectivity flags — as it did prior to v0.23.1, and as step ca provisioner add name --type=JWK --create --ca-config ca.json is still documented/shown in some examples without --ca-url.
Create a JWK provisioner and explicitly select the configuration file to update:
step ca provisioner add cicd --type JWK --create --ca-config /path/to/ca.json
This example is defined in the EXAMPLES section of add.go itself (command/ca/provisioner/add.go#L227-L229), and is reproduced verbatim in:
Neither the source comment, the rendered --help text, nor the public docs page have been updated to add --ca-url/--root to this example since #809 was merged, even though running it as written now fails with error generating admin client: 'step ca provisioner add' requires the '--ca-url' flag.
Actual Behavior
Running this
step ca provisioner add step-issuer --type=JWK --create \
--x509-default-dur=4368h --x509-min-dur=240h --x509-max-dur=8760h \
--password-file=/home/step/pass.txt \
--ca-config /home/step/config/ca.json
results in
error generating admin client: 'step ca provisioner add' requires the '--ca-url' flag
Adding --ca-url then immediately produces a second, similarly unconditional error:
error generating admin client: 'step ca provisioner add' requires the '--root' flag
Supplying both --ca-url (pointing at a Kubernetes Service hostname whose backing CA pod is not yet running/reachable) and --root (pointing at the just-created root cert on disk) does not fail immediately — instead, it can hang for the full HTTP client timeout before finally falling back to the local edit and failing:
step ca provisioner add step-issuer --type=JWK --create \
--x509-default-dur=4368h --x509-min-dur=240h --x509-max-dur=8760h \
--password-file=/home/step/pass.txt \
--ca-config /home/step/config/ca.json \
--ca-url "https://google.com:19999" \
--root /home/step/certs/root_ca.crt
client GET https://google.com:19999/admin/admins failed: context deadline exceeded (Client.Timeout exceeded while awaiting headers)
(Waited the full request timeout, then failed outright — the local ca.json edit never happens in this case.)
By contrast, a --ca-url that fails fast (connection refused, e.g. https://127.0.0.1:1, or unresolvable DNS) correctly falls back to the local edit within milliseconds:
step ca provisioner add step-issuer --type=JWK --create \
--x509-default-dur=4368h --x509-min-dur=240h --x509-max-dur=8760h \
--password-file=/home/step/pass.txt \
--ca-config /home/step/config/ca.json \
--ca-url "https://127.0.0.1:1" \
--root /home/step/certs/root_ca.crt
✔ CA Configuration: /home/step/config/ca.json
Success! Your `step-ca` config has been updated. ...
So the actual offline edit works fine — but only by accident, and only if the network error surfaces quickly. In a real Kubernetes environment, whether the CA's own not-yet-running Service address resolves to "fast connection refused" vs. "silent/hanging" is not something a bootstrap script can control or rely on, making this workflow flaky.
Additional Context
Root cause (traced in source)
In command/ca/provisioner/provisioner.go, newCRUDClient calls cautils.NewUnauthenticatedAdminClient(cliCtx) unconditionally before ever looking at cfgFile/--ca-config:
// utils/cautils/client.go
func NewUnauthenticatedAdminClient(ctx *cli.Context, opts ...ca.ClientOption) (*ca.AdminClient, error) {
caURL, err := flags.ParseCaURLIfExists(ctx)
if err != nil {
return nil, err
}
if caURL == "" {
return nil, errs.RequiredFlag(ctx, "ca-url") // <-- hard-fails here if --ca-url absent
}
root := ctx.String("root")
if root == "" {
root = pki.GetRootCAPath()
if _, err := os.Stat(root); err != nil {
return nil, errs.RequiredFlag(ctx, "root") // <-- hard-fails here if no root cert found
}
}
...
return ca.NewAdminClient(caURL, opts...)
}
Only once an AdminClient is successfully constructed does provisioner.go's newCRUDClient call .IsEnabled() and inspect the resulting error type to decide whether to fall back to local editing:
// command/ca/provisioner/provisioner.go
func newCRUDClient(cliCtx *cli.Context, cfgFile string) (crudClient, error) {
unauthAdminClient, err := cautils.NewUnauthenticatedAdminClient(cliCtx)
if err != nil {
return nil, fmt.Errorf("error generating admin client: %w", err)
}
var netErr *net.OpError
err = unauthAdminClient.IsEnabled()
switch {
case errors.As(err, &netErr) || errors.Is(err, ca.ErrAdminAPINotImplemented):
// falls back to local ca.json edit
...
case errors.Is(err, ca.ErrAdminAPINotAuthorized):
return cautils.NewAdminClient(cliCtx)
default:
return nil, err
}
}
This logic was introduced in PR #809 ("Check for remote admin api before prompting for creds", commit 4c0891c03ecd06c71afd3cf08716e6d3c558f8d7), first released in v0.23.1. Before that commit, newCRUDClient checked --ca-config/cfgFile first and went straight to the local-edit path (newCaConfigClient) if the file existed — no --ca-url/--root were required at all in that case.
This is the same category of bug previously reported and fixed for step ca renew --offline in #627 ("Offline step ca renew insists on --ca-url and --root parameters") — but that fix was specific to the renew/certificate offline code path (cautils.NewClient + flags.Offline) and does not cover step ca provisioner add/update/remove, which use the separate newCRUDClient path shown above and have no --offline flag at all.
Related issues
Contributing
Vote on this issue by adding a 👍 reaction.
To contribute a fix for this issue, leave a comment (and link to your pull request, if you've opened one already).
Steps to Reproduce
Reproduced on
0.24.x–0.30.6; does not reproduce on0.22.0(pre-#809 behavior)Create the ca.json (no
step ca init, no defaults.json — the CA has never run) and a root cert, exactly as an offline bootstrap tool would produce them.Your Environment
0.30.6Expected Behavior
Since
--ca-configpoints at an existing, validca.jsonfile and there is no CA to talk to,stepshould edit that file locally without requiring any CA connectivity flags — as it did prior to v0.23.1, and asstep ca provisioner add name --type=JWK --create --ca-config ca.jsonis still documented/shown in some examples without--ca-url.This example is defined in the
EXAMPLESsection ofadd.goitself (command/ca/provisioner/add.go#L227-L229), and is reproduced verbatim in:--helpoutput of the CLI (confirmed on0.30.6:step ca provisioner add --help)Neither the source comment, the rendered
--helptext, nor the public docs page have been updated to add--ca-url/--rootto this example since #809 was merged, even though running it as written now fails witherror generating admin client: 'step ca provisioner add' requires the '--ca-url' flag.Actual Behavior
Running this
results in
Adding
--ca-urlthen immediately produces a second, similarly unconditional error:Supplying both
--ca-url(pointing at a Kubernetes Service hostname whose backing CA pod is not yet running/reachable) and--root(pointing at the just-created root cert on disk) does not fail immediately — instead, it can hang for the full HTTP client timeout before finally falling back to the local edit and failing:step ca provisioner add step-issuer --type=JWK --create \ --x509-default-dur=4368h --x509-min-dur=240h --x509-max-dur=8760h \ --password-file=/home/step/pass.txt \ --ca-config /home/step/config/ca.json \ --ca-url "https://google.com:19999" \ --root /home/step/certs/root_ca.crt client GET https://google.com:19999/admin/admins failed: context deadline exceeded (Client.Timeout exceeded while awaiting headers)(Waited the full request timeout, then failed outright — the local
ca.jsonedit never happens in this case.)By contrast, a
--ca-urlthat fails fast (connection refused, e.g.https://127.0.0.1:1, or unresolvable DNS) correctly falls back to the local edit within milliseconds:step ca provisioner add step-issuer --type=JWK --create \ --x509-default-dur=4368h --x509-min-dur=240h --x509-max-dur=8760h \ --password-file=/home/step/pass.txt \ --ca-config /home/step/config/ca.json \ --ca-url "https://127.0.0.1:1" \ --root /home/step/certs/root_ca.crt ✔ CA Configuration: /home/step/config/ca.json Success! Your `step-ca` config has been updated. ...So the actual offline edit works fine — but only by accident, and only if the network error surfaces quickly. In a real Kubernetes environment, whether the CA's own not-yet-running Service address resolves to "fast connection refused" vs. "silent/hanging" is not something a bootstrap script can control or rely on, making this workflow flaky.
Additional Context
Root cause (traced in source)
In
command/ca/provisioner/provisioner.go,newCRUDClientcallscautils.NewUnauthenticatedAdminClient(cliCtx)unconditionally before ever looking atcfgFile/--ca-config:Only once an
AdminClientis successfully constructed doesprovisioner.go'snewCRUDClientcall.IsEnabled()and inspect the resulting error type to decide whether to fall back to local editing:This logic was introduced in PR #809 ("Check for remote admin api before prompting for creds", commit
4c0891c03ecd06c71afd3cf08716e6d3c558f8d7), first released in v0.23.1. Before that commit,newCRUDClientchecked--ca-config/cfgFilefirst and went straight to the local-edit path (newCaConfigClient) if the file existed — no--ca-url/--rootwere required at all in that case.This is the same category of bug previously reported and fixed for
step ca renew --offlinein #627 ("Offlinestep ca renewinsists on --ca-url and --root parameters") — but that fix was specific to therenew/certificateoffline code path (cautils.NewClient+flags.Offline) and does not coverstep ca provisioner add/update/remove, which use the separatenewCRUDClientpath shown above and have no--offlineflag at all.Related issues
step ca renewinsists on --ca-url and --root parameters (same category, different command, fixed by gating on--offline)step ca provisioner addpanics on fresh k8s install from helm chart (older, different failure mode, but same general theme ofprovisioner addbeing awkward to use against a CA that isn't fully live yet)Contributing
Vote on this issue by adding a 👍 reaction.
To contribute a fix for this issue, leave a comment (and link to your pull request, if you've opened one already).