Skip to content

[Bug]: step ca provisioner add --ca-config requires --ca-url/--root even for purely local/offline edits #1705

Description

@a-monahan

Steps to Reproduce

Reproduced on 0.24.x0.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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugneeds triageWaiting for discussion / prioritization by team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions