From 7319fe55859f99f2bb46e6679f21888007b7ed25 Mon Sep 17 00:00:00 2001 From: mea <215261208+YuukiRitoTeng@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:29:53 +0800 Subject: [PATCH] ca: add token-file support to certificate Signed-off-by: mea <215261208+YuukiRitoTeng@users.noreply.github.com> --- CHANGELOG.md | 2 + command/ca/certificate.go | 95 +++++++++--- command/ca/certificate_test.go | 276 +++++++++++++++++++++++++++++++++ 3 files changed, 350 insertions(+), 23 deletions(-) create mode 100644 command/ca/certificate_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b208082..ebb6433b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- Add `--token-file` support to `step ca certificate` to read the one-time + token from a file (smallstep/cli#1435). - Support for inspecting certificates with post-quantum algorithms ML-DSA and SLH-DSA (smallstep/certinfo#69). diff --git a/command/ca/certificate.go b/command/ca/certificate.go index 74ea4db9..bbd4b96e 100644 --- a/command/ca/certificate.go +++ b/command/ca/certificate.go @@ -15,6 +15,7 @@ import ( "github.com/smallstep/cli/flags" "github.com/smallstep/cli/token" + "github.com/smallstep/cli/utils" "github.com/smallstep/cli/utils/cautils" ) @@ -24,7 +25,8 @@ func certificateCommand() cli.Command { Action: command.ActionFunc(certificateAction), Usage: "generate a new private key and certificate signed by the root certificate", UsageText: `**step ca certificate** -[**--token**=] [**--issuer**=] [**--provisioner-password-file**=] +[**--token**=] [**--token-file**=] [**--issuer**=] +[**--provisioner-password-file**=] [**--not-before**=] [**--not-after**=] [**--san**=] [**--set**=] [**--set-file**=] [**--acme**=] [**--standalone**] [**--webroot**=] @@ -162,7 +164,8 @@ $ step ca certificate foo.internal foo.crt foo.key \ Name: "san", Usage: `Add Subject Alternative Name(s) (SANs) that should be authorized. Use the '--san' flag multiple times to configure -multiple SANs. The '--san' flag and the '--token' flag are mutually exclusive.`, +multiple SANs. The '--san' flag and the '--token' / '--token-file' flags are +mutually exclusive for JWK tokens.`, }, cli.StringFlag{ Name: "attestation-ca-url", @@ -188,6 +191,12 @@ multiple SANs. The '--san' flag and the '--token' flag are mutually exclusive.`, flags.CaURL, flags.Root, flags.Token, + cli.StringFlag{ + Name: "token-file", + Usage: `The path to the containing the one-time used to +authenticate with the CA in order to create the certificate. The '--token-file' +flag and the '--token' flag are mutually exclusive.`, + }, flags.Context, flags.Provisioner, flags.ProvisionerPasswordFile, @@ -231,15 +240,18 @@ func certificateAction(ctx *cli.Context) error { subject := args.Get(0) crtFile, keyFile := args.Get(1), args.Get(2) - tok := ctx.String("token") + tok, tokSource, err := resolveCertificateToken(ctx) + if err != nil { + return err + } offline := ctx.Bool("offline") sans := ctx.StringSlice("san") switch { - case offline && tok != "": - // offline and token are incompatible because the token is generated before - // the start of the offline CA. - return errs.IncompatibleFlagWithFlag(ctx, "offline", "token") + case offline && tokSource != "": + // offline and externally supplied tokens are incompatible because the + // token is generated before the start of the offline CA. + return errs.IncompatibleFlagWithFlag(ctx, "offline", tokSource) case ctx.String("attestation-uri") != "" && ctx.String("kms") != "": // attestation-uri and kms are incompatible because the ACME-DA flow // expects all necessary parameters in the attestation-uri, and having @@ -272,6 +284,55 @@ func certificateAction(ctx *cli.Context) error { return err } + if err := validateTokenType(ctx, tokSource, subject, req.CsrPEM.Subject.CommonName, tok, sans); err != nil { + return err + } + + if err := flow.Sign(ctx, tok, req.CsrPEM, crtFile); err != nil { + return err + } + + _, err = pemutil.Serialize(pk, pemutil.ToFile(keyFile, 0600)) + if err != nil { + return err + } + + ui.PrintSelected("Certificate", crtFile) + ui.PrintSelected("Private Key", keyFile) + return nil +} + +// resolveCertificateToken returns the externally supplied certificate token +// from either the --token or --token-file flag, along with the name of the +// flag used to supply it. If no external token source is provided, it returns +// an empty token and an empty source so that the caller can generate a token. +func resolveCertificateToken(ctx *cli.Context) (tok, source string, err error) { + tok = ctx.String("token") + tokenFile := ctx.String("token-file") + switch { + case tok != "" && tokenFile != "": + return "", "", errs.MutuallyExclusiveFlags(ctx, "token", "token-file") + case tokenFile != "": + b, err := utils.ReadFile(tokenFile) + if err != nil { + return "", "", err + } + tok = strings.TrimSpace(string(b)) + if tok == "" { + return "", "", errors.Errorf("file '%s' is empty or contains only whitespace", tokenFile) + } + source = "token-file" + case tok != "": + source = "token" + } + return tok, source, nil +} + +// validateTokenType validates the token payload after the token has been +// resolved. The source flag is the name of the flag used to supply an +// externally provided token, or empty if the token was generated by the +// certificate flow. +func validateTokenType(ctx *cli.Context, source, subject, csrCommonName, tok string, sans []string) error { jwt, err := token.ParseInsecure(tok) if err != nil { return err @@ -279,11 +340,11 @@ func certificateAction(ctx *cli.Context) error { switch jwt.Payload.Type() { case token.JWK: // Validate that subject matches the CSR common name. - if ctx.String("token") != "" && len(sans) > 0 { - return errs.MutuallyExclusiveFlags(ctx, "token", "san") + if source != "" && len(sans) > 0 { + return errs.MutuallyExclusiveFlags(ctx, source, "san") } - if !strings.EqualFold(subject, req.CsrPEM.Subject.CommonName) { - return errors.Errorf("token subject '%s' and argument '%s' do not match", req.CsrPEM.Subject.CommonName, subject) + if !strings.EqualFold(subject, csrCommonName) { + return errors.Errorf("token subject '%s' and argument '%s' do not match", csrCommonName, subject) } case token.OIDC, token.AWS, token.GCP, token.Azure, token.K8sSA: // Common name will be validated on the server side, it depends on @@ -291,17 +352,5 @@ func certificateAction(ctx *cli.Context) error { default: return errors.New("token is not supported") } - - if err := flow.Sign(ctx, tok, req.CsrPEM, crtFile); err != nil { - return err - } - - _, err = pemutil.Serialize(pk, pemutil.ToFile(keyFile, 0600)) - if err != nil { - return err - } - - ui.PrintSelected("Certificate", crtFile) - ui.PrintSelected("Private Key", keyFile) return nil } diff --git a/command/ca/certificate_test.go b/command/ca/certificate_test.go new file mode 100644 index 00000000..c0fc58e9 --- /dev/null +++ b/command/ca/certificate_test.go @@ -0,0 +1,276 @@ +package ca + +import ( + "flag" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli" + "go.step.sm/crypto/jose" + + "github.com/smallstep/cli/token" +) + +// newCertificateContext builds a cli.Context with the certificate command +// flags registered and the given arguments parsed. +func newCertificateContext(t *testing.T, args ...string) *cli.Context { + t.Helper() + set := flag.NewFlagSet("certificate", flag.ContinueOnError) + cmd := certificateCommand() + for _, f := range cmd.Flags { + f.Apply(set) + } + ctx := cli.NewContext(&cli.App{}, set, nil) + ctx.Command = cmd + require.NoError(t, set.Parse(args)) + return ctx +} + +// writeTokenFile writes the given content to a file inside dir and returns its +// path. +func writeTokenFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +// newJWKToken returns a signed JWK token for the given subject using a +// non-HTTP audience so that certificate issuance fails before any network +// access. +func newJWKToken(t *testing.T, subject string) string { + t.Helper() + jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "", "", 0) + require.NoError(t, err) + claims, err := token.NewClaims( + token.WithSubject(subject), + token.WithAudience("step-ca://sign"), + token.WithSHA("test-sha"), + ) + require.NoError(t, err) + signed, err := claims.Sign(jose.ES256, jwk.Key) + require.NoError(t, err) + return signed +} + +// newOIDCToken returns a signed OIDC token. +func newOIDCToken(t *testing.T) string { + t.Helper() + jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "", "", 0) + require.NoError(t, err) + claims, err := token.NewClaims(token.WithSubject("user@example.com")) + require.NoError(t, err) + signed, err := claims.Sign(jose.ES256, jwk.Key) + require.NoError(t, err) + return signed +} + +func TestCertificateCommand_tokenFileFlag(t *testing.T) { + cmd := certificateCommand() + + var found *cli.StringFlag + for i := range cmd.Flags { + if f, ok := cmd.Flags[i].(cli.StringFlag); ok && f.Name == "token-file" { + found = &f + break + } + } + require.NotNil(t, found, "expected a '--token-file' flag to be registered") + assert.NotEmpty(t, found.Usage) + assert.Contains(t, cmd.UsageText, "--token-file") + assert.Contains(t, cmd.UsageText, "=") +} + +func Test_resolveCertificateToken(t *testing.T) { + dir := t.TempDir() + tokenPath := writeTokenFile(t, dir, "token.txt", "jwt-token") + emptyPath := writeTokenFile(t, dir, "empty.txt", "") + spacePath := writeTokenFile(t, dir, "space.txt", " \n\t\n") + missingPath := filepath.Join(dir, "missing.txt") + + tests := []struct { + name string + args []string + wantTok string + wantSource string + wantErr string + }{ + { + name: "no external token source", + args: []string{"internal.example.com", "cert.crt", "key.key"}, + wantTok: "", + wantSource: "", + }, + { + name: "explicit token", + args: []string{"--token", "jwt-token", "internal.example.com", "cert.crt", "key.key"}, + wantTok: "jwt-token", + wantSource: "token", + }, + { + name: "token file", + args: []string{"--token-file", tokenPath, "internal.example.com", "cert.crt", "key.key"}, + wantTok: "jwt-token", + wantSource: "token-file", + }, + { + name: "token file with trailing newline", + args: []string{"--token-file", writeTokenFile(t, dir, "newline.txt", "jwt-token\n"), "internal.example.com", "cert.crt", "key.key"}, + wantTok: "jwt-token", + wantSource: "token-file", + }, + { + name: "token file with surrounding whitespace", + args: []string{"--token-file", writeTokenFile(t, dir, "spacey.txt", " jwt-token \n"), "internal.example.com", "cert.crt", "key.key"}, + wantTok: "jwt-token", + wantSource: "token-file", + }, + { + name: "token and token file", + args: []string{"--token", "jwt-token", "--token-file", tokenPath, "internal.example.com", "cert.crt", "key.key"}, + wantErr: "flag '--token' and flag '--token-file' are mutually exclusive", + }, + { + name: "empty token file", + args: []string{"--token-file", emptyPath, "internal.example.com", "cert.crt", "key.key"}, + wantErr: "is empty or contains only whitespace", + }, + { + name: "whitespace-only token file", + args: []string{"--token-file", spacePath, "internal.example.com", "cert.crt", "key.key"}, + wantErr: "is empty or contains only whitespace", + }, + { + name: "missing token file", + args: []string{"--token-file", missingPath, "internal.example.com", "cert.crt", "key.key"}, + wantErr: missingPath, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := newCertificateContext(t, tt.args...) + gotTok, gotSource, err := resolveCertificateToken(ctx) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantTok, gotTok) + assert.Equal(t, tt.wantSource, gotSource) + }) + } +} + +func Test_certificateAction_tokenSource(t *testing.T) { + dir := t.TempDir() + tok := newJWKToken(t, "internal.example.com") + tokenPath := writeTokenFile(t, dir, "token.txt", tok) + emptyPath := writeTokenFile(t, dir, "empty.txt", "") + missingPath := filepath.Join(dir, "missing.txt") + + tests := []struct { + name string + args []string + wantErr string + exclude []string + }{ + { + name: "token and token file", + args: []string{"--token", tok, "--token-file", tokenPath, "internal.example.com", "cert.crt", "key.key"}, + wantErr: "flag '--token' and flag '--token-file' are mutually exclusive", + }, + { + name: "offline and token file", + args: []string{"--offline", "--token-file", tokenPath, "internal.example.com", "cert.crt", "key.key"}, + wantErr: "flag '--offline' is incompatible with '--token-file'", + }, + { + name: "offline and token", + args: []string{"--offline", "--token", tok, "internal.example.com", "cert.crt", "key.key"}, + wantErr: "flag '--offline' is incompatible with '--token'", + }, + { + name: "empty token file", + args: []string{"--token-file", emptyPath, "internal.example.com", "cert.crt", "key.key"}, + wantErr: "is empty or contains only whitespace", + }, + { + name: "missing token file", + args: []string{"--token-file", missingPath, "internal.example.com", "cert.crt", "key.key"}, + wantErr: missingPath, + }, + { + name: "valid token file reaches the certificate flow", + args: []string{"--token-file", tokenPath, "internal.example.com", "cert.crt", "key.key"}, + wantErr: "requires the '--ca-url' flag", + exclude: []string{"unless", "empty", "mutually exclusive"}, + }, + { + name: "valid explicit token reaches the certificate flow", + args: []string{"--token", tok, "internal.example.com", "cert.crt", "key.key"}, + wantErr: "requires the '--ca-url' flag", + exclude: []string{"unless", "empty", "mutually exclusive"}, + }, + { + name: "no external token uses automatic generation", + args: []string{"internal.example.com", "cert.crt", "key.key"}, + wantErr: "flag '--ca-url' is required unless the '--token' flag is provided", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := newCertificateContext(t, tt.args...) + err := certificateAction(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + for _, s := range tt.exclude { + assert.NotContains(t, err.Error(), s) + } + }) + } +} + +func Test_certificateAction_jwkSAN(t *testing.T) { + dir := t.TempDir() + jwkTok := newJWKToken(t, "internal.example.com") + jwkTokenPath := writeTokenFile(t, dir, "jwk.txt", jwkTok) + oidcTok := newOIDCToken(t) + oidcTokenPath := writeTokenFile(t, dir, "oidc.txt", oidcTok) + + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "JWK token-file and san are mutually exclusive", + args: []string{"--token-file", jwkTokenPath, "--san", "internal.example.com", "internal.example.com", "cert.crt", "key.key"}, + wantErr: "flag '--token-file' and flag '--san' are mutually exclusive", + }, + { + name: "JWK token and san are mutually exclusive", + args: []string{"--token", jwkTok, "--san", "internal.example.com", "internal.example.com", "cert.crt", "key.key"}, + wantErr: "flag '--token' and flag '--san' are mutually exclusive", + }, + { + name: "OIDC token-file and san are not mutually exclusive", + args: []string{"--token-file", oidcTokenPath, "--san", "internal.example.com", "internal.example.com", "cert.crt", "key.key"}, + wantErr: "requires the '--ca-url' flag", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := newCertificateContext(t, tt.args...) + err := certificateAction(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +}