Skip to content
Draft
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
20 changes: 17 additions & 3 deletions command/ca/acme/eab/add.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package eab

import (
"encoding/json"
"fmt"
"os"

Expand All @@ -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** <provisioner> [<eab-key-reference>]
[**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--json**] [**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--admin-provisioner**=<name>] [**--admin-password-file**=<file>]
[**--ca-url**=<uri>] [**--root**=<file>] [**--context**=<name>]`,
Flags: []cli.Flag{
jsonFlag,
flags.AdminCert,
flags.AdminKey,
flags.AdminSubject,
Expand Down Expand Up @@ -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
'''`,
}
}
Expand Down Expand Up @@ -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
}
38 changes: 24 additions & 14 deletions command/ca/acme/eab/eab.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
100 changes: 100 additions & 0 deletions command/ca/acme/eab/eab_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
52 changes: 44 additions & 8 deletions command/ca/acme/eab/list.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package eab

import (
"encoding/json"
"fmt"
"io"
"os"
Expand All @@ -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** <provisioner> [<eab-key-reference>]
[**--limit**=<number>]
[**--json**] [**--limit**=<number>]
[**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--admin-provisioner**=<name>] [**--admin-password-file**=<file>]
[**--ca-url**=<uri>] [**--root**=<file>] [**--context**=<name>]`,
Flags: []cli.Flag{
jsonFlag,
flags.Limit,
flags.NoPager,
flags.AdminCert,
Expand Down Expand Up @@ -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
'''
`,
}
}
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
26 changes: 25 additions & 1 deletion command/ca/acme/eab/remove.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package eab

import (
"encoding/json"
"fmt"

"github.com/pkg/errors"
Expand All @@ -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** <provisioner> <eab-key-id>
[**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--json**] [**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--admin-provisioner**=<name>] [**--admin-password-file**=<file>]
[**--ca-url**=<uri>] [**--root**=<file>] [**--context**=<name>]`,
Flags: []cli.Flag{
jsonFlag,
flags.AdminCert,
flags.AdminKey,
flags.AdminSubject,
Expand All @@ -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
'''
`,
}
}
Expand All @@ -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
Expand Down