diff --git a/command/ca/acme/eab/add.go b/command/ca/acme/eab/add.go index 2fe70c794..d803a232b 100644 --- a/command/ca/acme/eab/add.go +++ b/command/ca/acme/eab/add.go @@ -1,6 +1,7 @@ package eab import ( + "encoding/json" "fmt" "os" @@ -20,10 +21,11 @@ func addCommand() cli.Command { Action: cli.ActionFunc(addAction), Usage: "add ACME External Account Binding Key", UsageText: `**step ca acme eab add** [] -[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--json**] [**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] [**--ca-url**=] [**--root**=] [**--context**=]`, Flags: []cli.Flag{ + jsonFlag, flags.AdminCert, flags.AdminKey, flags.AdminSubject, @@ -53,6 +55,11 @@ $ step ca acme eab add my_acme_provisioner Add an ACME External Account Binding Key with reference: ''' $ step ca acme eab add my_acme_provisioner my_first_eab_key +''' + +Add an ACME External Account Binding Key and output the result as JSON: +''' +$ step ca acme eab add my_acme_provisioner my_first_eab_key --json '''`, } } @@ -84,12 +91,19 @@ func addAction(ctx *cli.Context) (err error) { cliEAK := toCLI(ctx, client, eak) - // TODO(hs): JSON output, so that executing this command can be more easily automated? + if ctx.Bool("json") { + b, err := json.MarshalIndent(cliEAK, "", " ") + if err != nil { + return errors.Wrap(err, "error marshaling ACME EAB key") + } + fmt.Println(string(b)) + return nil + } out := os.Stdout format := "%-36s%-28s%-48s%s\n" fmt.Fprintf(out, format, "Key ID", "Provisioner", "Key (base64, raw url encoded)", "Reference") - fmt.Fprintf(out, format, cliEAK.id, cliEAK.provisioner, cliEAK.key, cliEAK.reference) + fmt.Fprintf(out, format, cliEAK.ID, cliEAK.Provisioner, cliEAK.Key, cliEAK.Reference) return nil } diff --git a/command/ca/acme/eab/eab.go b/command/ca/acme/eab/eab.go index b989482b6..4c0c517ca 100644 --- a/command/ca/acme/eab/eab.go +++ b/command/ca/acme/eab/eab.go @@ -16,31 +16,41 @@ import ( ) type cliEAK struct { - id string - provisioner string - reference string - key string - createdAt string - boundAt string - account string + ID string `json:"id"` + Provisioner string `json:"provisioner"` + Reference string `json:"reference"` + Key string `json:"key,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + BoundAt string `json:"boundAt,omitempty"` + Account string `json:"account,omitempty"` } func toCLI(_ *cli.Context, _ *ca.AdminClient, eak *linkedca.EABKey) *cliEAK { + createdAt := "" + if !eak.CreatedAt.AsTime().IsZero() { + createdAt = eak.CreatedAt.AsTime().Format("2006-01-02 15:04:05 -07:00") + } boundAt := "" if !eak.BoundAt.AsTime().IsZero() { boundAt = eak.BoundAt.AsTime().Format("2006-01-02 15:04:05 -07:00") } return &cliEAK{ - id: eak.Id, - provisioner: eak.Provisioner, - reference: eak.Reference, - key: base64.RawURLEncoding.Strict().EncodeToString(eak.HmacKey), - createdAt: eak.CreatedAt.AsTime().Format("2006-01-02 15:04:05 -07:00"), - boundAt: boundAt, - account: eak.Account, + ID: eak.Id, + Provisioner: eak.Provisioner, + Reference: eak.Reference, + Key: base64.RawURLEncoding.Strict().EncodeToString(eak.HmacKey), + CreatedAt: createdAt, + BoundAt: boundAt, + Account: eak.Account, } } +// jsonFlag toggles machine-readable JSON output for the eab subcommands. +var jsonFlag = cli.BoolFlag{ + Name: "json", + Usage: `Print output as a JSON object (or array) instead of the human-readable table.`, +} + // Command returns the eab subcommand. func Command() cli.Command { return cli.Command{ diff --git a/command/ca/acme/eab/eab_test.go b/command/ca/acme/eab/eab_test.go new file mode 100644 index 000000000..23e6f97cc --- /dev/null +++ b/command/ca/acme/eab/eab_test.go @@ -0,0 +1,100 @@ +package eab + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/smallstep/linkedca" +) + +func TestToCLI(t *testing.T) { + created := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) + bound := time.Date(2026, 7, 6, 13, 30, 0, 0, time.UTC) + + t.Run("bound key", func(t *testing.T) { + eak := &linkedca.EABKey{ + Id: "keyid", + Provisioner: "my_acme", + Reference: "my_ref", + HmacKey: []byte("secret-hmac-key"), + Account: "acct-1", + CreatedAt: timestamppb.New(created), + BoundAt: timestamppb.New(bound), + } + + got := toCLI(nil, nil, eak) + assert.Equal(t, "keyid", got.ID) + assert.Equal(t, "my_acme", got.Provisioner) + assert.Equal(t, "my_ref", got.Reference) + assert.Equal(t, "c2VjcmV0LWhtYWMta2V5", got.Key) // base64 raw-url of the hmac key + assert.Equal(t, "acct-1", got.Account) + assert.Equal(t, "2026-07-06 12:00:00 +00:00", got.CreatedAt) + assert.Equal(t, "2026-07-06 13:30:00 +00:00", got.BoundAt) + }) + + t.Run("unbound key with zero timestamps", func(t *testing.T) { + eak := &linkedca.EABKey{ + Id: "keyid", + Provisioner: "my_acme", + CreatedAt: timestamppb.New(time.Time{}), + BoundAt: timestamppb.New(time.Time{}), + } + + got := toCLI(nil, nil, eak) + // zero timestamps are represented as empty strings so they are dropped + // from JSON via omitempty. + assert.Empty(t, got.CreatedAt) + assert.Empty(t, got.BoundAt) + assert.Empty(t, got.Account) + }) +} + +func TestCLIEAK_JSON(t *testing.T) { + t.Run("add output includes key and omits empty fields", func(t *testing.T) { + eak := &cliEAK{ + ID: "keyid", + Provisioner: "my_acme", + Reference: "my_ref", + Key: "c2VjcmV0", + } + + b, err := json.Marshal(eak) + require.NoError(t, err) + + var m map[string]any + require.NoError(t, json.Unmarshal(b, &m)) + assert.Equal(t, "keyid", m["id"]) + assert.Equal(t, "my_acme", m["provisioner"]) + assert.Equal(t, "my_ref", m["reference"]) + assert.Equal(t, "c2VjcmV0", m["key"]) + assert.NotContains(t, m, "createdAt") + assert.NotContains(t, m, "boundAt") + assert.NotContains(t, m, "account") + }) + + t.Run("list output omits the secret key", func(t *testing.T) { + eak := &cliEAK{ + ID: "keyid", + Provisioner: "my_acme", + Reference: "my_ref", + Key: "", // cleared for list output + CreatedAt: "2026-07-06 12:00:00 +00:00", + Account: "acct-1", + } + + b, err := json.Marshal(eak) + require.NoError(t, err) + + var m map[string]any + require.NoError(t, json.Unmarshal(b, &m)) + assert.NotContains(t, m, "key") + assert.Equal(t, "2026-07-06 12:00:00 +00:00", m["createdAt"]) + assert.Equal(t, "acct-1", m["account"]) + assert.NotContains(t, m, "boundAt") + }) +} diff --git a/command/ca/acme/eab/list.go b/command/ca/acme/eab/list.go index 51e1bfe5b..59383a8fd 100644 --- a/command/ca/acme/eab/list.go +++ b/command/ca/acme/eab/list.go @@ -1,6 +1,7 @@ package eab import ( + "encoding/json" "fmt" "io" "os" @@ -24,11 +25,12 @@ func listCommand() cli.Command { Action: cli.ActionFunc(listAction), Usage: "list all ACME External Account Binding Keys", UsageText: `**step ca acme eab list** [] -[**--limit**=] +[**--json**] [**--limit**=] [**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] [**--ca-url**=] [**--root**=] [**--context**=]`, Flags: []cli.Flag{ + jsonFlag, flags.Limit, flags.NoPager, flags.AdminCert, @@ -64,6 +66,11 @@ Show ACME External Account Binding Key with specific reference: ''' $ step ca acme eab list my_acme_provisioner my_reference ''' + +List all ACME External Account Binding Keys as a JSON array (keys are omitted): +''' +$ step ca acme eab list my_acme_provisioner --json +''' `, } } @@ -86,6 +93,41 @@ func listAction(ctx *cli.Context) (err error) { return errors.Wrap(err, "error creating admin client") } + // default to API paging per 100 entities + limit := uint(0) + if ctx.IsSet("limit") { + limit = ctx.Uint("limit") + } + + // JSON output collects every page into a single array and bypasses the + // $PAGER machinery, which only makes sense for the human-readable table. + if ctx.Bool("json") { + eaks := []*cliEAK{} + cursor := "" + for { + options := []ca.AdminOption{ca.WithAdminCursor(cursor), ca.WithAdminLimit(cast.Int(limit))} + eaksResponse, err := client.GetExternalAccountKeysPaginate(provisioner, reference, options...) + if err != nil { + return errors.Wrap(notImplemented(err), "error retrieving ACME EAB keys") + } + for _, k := range eaksResponse.EAKs { + cliEAK := toCLI(ctx, client, k) + cliEAK.Key = "" // never expose the secret HMAC key in list output + eaks = append(eaks, cliEAK) + } + if eaksResponse.NextCursor == "" { + break + } + cursor = eaksResponse.NextCursor + } + b, err := json.MarshalIndent(eaks, "", " ") + if err != nil { + return errors.Wrap(err, "error marshaling ACME EAB keys") + } + fmt.Println(string(b)) + return nil + } + var out io.WriteCloser var cmd *exec.Cmd @@ -121,12 +163,6 @@ func listAction(ctx *cli.Context) (err error) { out = os.Stdout } - // default to API paging per 100 entities - limit := uint(0) - if ctx.IsSet("limit") { - limit = ctx.Uint("limit") - } - cursor := "" format := "%-36s%-28s%-16s%-30s%-30s%-40s%s\n" firstIteration := true @@ -154,7 +190,7 @@ func listAction(ctx *cli.Context) (err error) { } for _, k := range eaksResponse.EAKs { cliEAK := toCLI(ctx, client, k) - _, err = fmt.Fprintf(out, format, cliEAK.id, cliEAK.provisioner, "*****", cliEAK.createdAt, cliEAK.boundAt, cliEAK.account, cliEAK.reference) + _, err = fmt.Fprintf(out, format, cliEAK.ID, cliEAK.Provisioner, "*****", cliEAK.CreatedAt, cliEAK.BoundAt, cliEAK.Account, cliEAK.Reference) if err != nil { return errors.Wrap(err, "error writing ACME EAB key to output") } diff --git a/command/ca/acme/eab/remove.go b/command/ca/acme/eab/remove.go index 857a4df1a..9280994dc 100644 --- a/command/ca/acme/eab/remove.go +++ b/command/ca/acme/eab/remove.go @@ -1,6 +1,7 @@ package eab import ( + "encoding/json" "fmt" "github.com/pkg/errors" @@ -18,10 +19,11 @@ func removeCommand() cli.Command { Action: cli.ActionFunc(removeAction), Usage: "remove an ACME EAB Key from the CA", UsageText: `**step ca acme eab remove** -[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--json**] [**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] [**--ca-url**=] [**--root**=] [**--context**=]`, Flags: []cli.Flag{ + jsonFlag, flags.AdminCert, flags.AdminKey, flags.AdminSubject, @@ -47,6 +49,11 @@ Remove ACME EAB Key with Key ID "zFGdKC1sHmNf3Wsx3OujY808chxwEdmr" from my_acme_ ''' $ step ca acme eab remove my_acme_provisioner zFGdKC1sHmNf3Wsx3OujY808chxwEdmr ''' + +Remove an ACME EAB Key and output the result as JSON: +''' +$ step ca acme eab remove my_acme_provisioner zFGdKC1sHmNf3Wsx3OujY808chxwEdmr --json +''' `, } } @@ -70,6 +77,23 @@ func removeAction(ctx *cli.Context) error { return errors.Wrap(notImplemented(err), "error removing ACME EAB key") } + if ctx.Bool("json") { + b, err := json.MarshalIndent(struct { + Provisioner string `json:"provisioner"` + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + Provisioner: provisioner, + ID: keyID, + Deleted: true, + }, "", " ") + if err != nil { + return errors.Wrap(err, "error marshaling ACME EAB key") + } + fmt.Println(string(b)) + return nil + } + fmt.Println("Key was deleted successfully!") return nil