Skip to content
Open
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
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,50 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

### [x.y.z] - unreleased

### Added

- Add `--token` flag to `step-ca export` command to export provisioners and
admins from linked CAs before migrating to standalone mode.
- Add `step-ca import` command to import provisioners and admins from an export
file into a standalone CA's admin database. This enables migration from Linked
CA to standalone mode, or migration between standalone CAs. Features include:
- Automatic ID remapping for provisioners and admins
- Duplicate detection (skips existing provisioners by name, admins by subject)
- `--dry-run` flag to preview changes without modifying the database

### Deprecated

- Linked CA functionality is deprecated in open-source step-ca and will be
removed in a future version. Existing Linked CAs will continue to work but
will show a deprecation warning. Users requiring Linked CA features should
migrate to Step CA Pro. See https://smallstep.com/product/step-ca-pro/

#### Migrating from Linked CA to Standalone

To migrate an existing linked CA to standalone mode:

1. Export your current configuration including cloud-stored provisioners:
```
step-ca export $(step path)/config/ca.json --token $STEP_CA_TOKEN > export.json
```

2. Stop the CA

3. Update your `ca.json`:
- Remove the `authority.linkedca` section
- Ensure `authority.enableAdmin: true`
- Ensure `db` is configured

4. Import the provisioners and admins:
```
step-ca import $(step path)/config/ca.json export.json
```

5. Start the CA without the `--token` flag:
```
step-ca $(step path)/config/ca.json
```

### Changed

- Upgrade HSM-enabled Docker images from Debian Bookworm (12) to Debian Trixie
Expand Down
3 changes: 3 additions & 0 deletions authority/authority.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,9 @@ func (a *Authority) init() error {

// Automatically enable admin for all linked cas.
if a.linkedCAToken != "" {
log.Println("DEPRECATION WARNING: Linked CA functionality in open-source step-ca " +
"is deprecated and will be removed in a future version. Please migrate to " +
"Step CA Pro. See https://smallstep.com/product/step-ca-pro/")
a.config.AuthorityConfig.EnableAdmin = true
}

Expand Down
6 changes: 6 additions & 0 deletions commands/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ func appAction(ctx *cli.Context) error {
token := ctx.String("token")
quiet := ctx.Bool("quiet")

if token != "" {
fmt.Fprintln(os.Stderr, "DEPRECATION WARNING: Linked CA (--token) in open-source "+
"step-ca is deprecated and will be removed in a future version. "+
"Please unlink your CA or migrate to Step CA Pro. https://smallstep.com/product/step-ca-pro/")
}

if ctx.NArg() > 1 {
return errs.TooManyArguments(ctx)
}
Expand Down
16 changes: 15 additions & 1 deletion commands/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ func init() {
Note that neither the PKI password nor the certificate issuer password will be
included in the export file.

For linked CAs, use the **--token** flag to authenticate with the linked CA
service and export provisioners and admins stored in the cloud.

## POSITIONAL ARGUMENTS

<config>
Expand All @@ -39,6 +42,11 @@ included in the export file.
Export the current configuration:
'''
$ step-ca export $(step path)/config/ca.json
'''

Export the configuration of a linked CA:
'''
$ step-ca export $(step path)/config/ca.json --token $STEP_CA_TOKEN
'''`,
Flags: []cli.Flag{
cli.StringFlag{
Expand All @@ -51,6 +59,11 @@ intermediate private key.`,
Usage: `path to the <file> containing the password to decrypt the
certificate issuer private key used in the RA mode.`,
},
cli.StringFlag{
Name: "token",
Usage: "token used to enable the linked CA.",
EnvVar: "STEP_CA_TOKEN",
},
},
})
}
Expand All @@ -63,6 +76,7 @@ func exportAction(ctx *cli.Context) error {
configFile := ctx.Args().Get(0)
passwordFile := ctx.String("password-file")
issuerPasswordFile := ctx.String("issuer-password-file")
token := ctx.String("token")

cfg, err := config.LoadConfiguration(configFile)
if err != nil {
Expand All @@ -89,7 +103,7 @@ func exportAction(ctx *cli.Context) error {
}
}

auth, err := authority.New(cfg)
auth, err := authority.New(cfg, authority.WithLinkedCAToken(token))
if err != nil {
return err
}
Expand Down
243 changes: 243 additions & 0 deletions commands/import.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
package commands

import (
"context"
"fmt"
"os"

"github.com/pkg/errors"
"github.com/urfave/cli"
"google.golang.org/protobuf/encoding/protojson"

"github.com/smallstep/cli-utils/command"
"github.com/smallstep/cli-utils/errs"
"github.com/smallstep/linkedca"

"github.com/smallstep/certificates/authority/admin"
adminDBNosql "github.com/smallstep/certificates/authority/admin/db/nosql"
"github.com/smallstep/certificates/authority/config"
"github.com/smallstep/certificates/db"
)

func init() {
command.Register(cli.Command{
Name: "import",
Usage: "import provisioners and admins from an export file",
UsageText: "**step-ca import** <config> <export-file> [**--dry-run**]",
Action: importAction,
Description: `**step-ca import** imports provisioners and admins from an export file
into the CA's admin database.

This command is used to migrate from a Linked CA to a standalone CA, or to
migrate provisioners and admins between standalone CAs.

The CA must be stopped before running this command.

## POSITIONAL ARGUMENTS

<config>
: The ca.json configuration file. Must have 'authority.enableAdmin: true'
and a database configured.

<export-file>
: The export file created by 'step-ca export'.

## EXAMPLES

Import provisioners and admins from an export file:
'''
$ step-ca import $(step path)/config/ca.json export.json
'''

Preview the import without making changes:
'''
$ step-ca import $(step path)/config/ca.json export.json --dry-run
'''`,
Flags: []cli.Flag{
cli.BoolFlag{
Name: "dry-run",
Usage: "preview the import without making changes",
},
},
})
}

func importAction(ctx *cli.Context) error {
if err := errs.NumberOfArguments(ctx, 2); err != nil {
return err
}

configFile := ctx.Args().Get(0)
exportFile := ctx.Args().Get(1)
dryRun := ctx.Bool("dry-run")

// Load and validate configuration
cfg, err := config.LoadConfiguration(configFile)
if err != nil {
return err
}
if err := cfg.Validate(); err != nil {
return err
}

// Check that enableAdmin is true
if cfg.AuthorityConfig == nil || !cfg.AuthorityConfig.EnableAdmin {
return errors.New("authority.enableAdmin must be true to use the import command")
}

// Check that a database is configured
if cfg.DB == nil {
return errors.New("a database must be configured to use the import command")
}

// Read and parse export file
exportData, err := os.ReadFile(exportFile)
if err != nil {
return errors.Wrapf(err, "error reading export file %s", exportFile)
}

var export linkedca.ConfigurationResponse
if err := protojson.Unmarshal(exportData, &export); err != nil {
return errors.Wrap(err, "error parsing export file")
}

if dryRun {
fmt.Println("=== DRY RUN - No changes will be made ===")
fmt.Println()
}

// Open database
authDB, err := db.New(cfg.DB)
if err != nil {
return errors.Wrap(err, "error opening database")
}
defer func() {
if dbShutdown, ok := authDB.(interface{ Shutdown() error }); ok {
dbShutdown.Shutdown()
}
}()

// Get the nosql.DB interface from the wrapped DB
nosqlDB, ok := authDB.(*db.DB)
if !ok {
return errors.New("database does not support admin operations")
}

// Initialize admin DB
adminDB, err := adminDBNosql.New(nosqlDB.DB, admin.DefaultAuthorityID)
if err != nil {
return errors.Wrap(err, "error initializing admin database")
}

// Get existing provisioners for duplicate detection
existingProvs, err := adminDB.GetProvisioners(context.Background())
if err != nil {
return errors.Wrap(err, "error getting existing provisioners")
}

// Build map of existing provisioner names to IDs
existingProvsByName := make(map[string]string)
for _, p := range existingProvs {
existingProvsByName[p.Name] = p.Id
}

// Get existing admins for duplicate detection
existingAdmins, err := adminDB.GetAdmins(context.Background())
if err != nil {
return errors.Wrap(err, "error getting existing admins")
}

// Build set of existing admin subject+provisioner combos
existingAdminKeys := make(map[string]bool)
for _, a := range existingAdmins {
key := a.Subject + ":" + a.ProvisionerId
existingAdminKeys[key] = true
}

// Track old ID to new ID mappings for provisioners
provIDMap := make(map[string]string)

// Import provisioners first (admins reference them)
fmt.Printf("Importing %d provisioner(s)...\n", len(export.Provisioners))
var provsCreated, provsSkipped int
for _, prov := range export.Provisioners {
oldID := prov.Id

// Check for duplicate by name
if existingID, exists := existingProvsByName[prov.Name]; exists {
fmt.Printf(" Skipping provisioner %q: already exists\n", prov.Name)
provIDMap[oldID] = existingID
provsSkipped++
continue
}

if dryRun {
fmt.Printf(" Would create provisioner %q (type: %s)\n", prov.Name, prov.Type.String())
// For dry run, map old ID to itself since we won't create new ones
provIDMap[oldID] = oldID
provsCreated++
continue
}

// Clear ID so the database generates a new one
prov.Id = ""

if err := adminDB.CreateProvisioner(context.Background(), prov); err != nil {
return errors.Wrapf(err, "error creating provisioner %q", prov.Name)
}

fmt.Printf(" Created provisioner %q (type: %s)\n", prov.Name, prov.Type.String())
provIDMap[oldID] = prov.Id
provsCreated++
}

// Import admins with remapped provisioner IDs
fmt.Printf("Importing %d admin(s)...\n", len(export.Admins))
var adminsCreated, adminsSkipped int
for _, adm := range export.Admins {
// Remap provisioner ID
newProvID, ok := provIDMap[adm.ProvisionerId]
if !ok {
fmt.Printf(" Skipping admin %q: provisioner ID %s not found in export\n", adm.Subject, adm.ProvisionerId)
adminsSkipped++
continue
}

// Check for duplicate by subject+provisioner combo
key := adm.Subject + ":" + newProvID
if existingAdminKeys[key] {
fmt.Printf(" Skipping admin %q: already exists for this provisioner\n", adm.Subject)
adminsSkipped++
continue
}

if dryRun {
fmt.Printf(" Would create admin %q (type: %s)\n", adm.Subject, adm.Type.String())
adminsCreated++
continue
}

// Clear ID and update provisioner ID
adm.Id = ""
adm.ProvisionerId = newProvID

if err := adminDB.CreateAdmin(context.Background(), adm); err != nil {
return errors.Wrapf(err, "error creating admin %q", adm.Subject)
}

fmt.Printf(" Created admin %q (type: %s)\n", adm.Subject, adm.Type.String())
adminsCreated++
}

fmt.Println()
fmt.Printf("Import complete: %d provisioner(s) created, %d skipped; %d admin(s) created, %d skipped\n",
provsCreated, provsSkipped, adminsCreated, adminsSkipped)

if dryRun {
fmt.Println()
fmt.Println("=== DRY RUN - No changes were made ===")
fmt.Println("Run without --dry-run to perform the import.")
}

return nil
}