check / check (push) Failing after 1s
init and vault create put the mnemonic into the process environment for vault.CreateVault to read back, so every program they ran, gpg included, inherited it, and SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE were read at 13 places and never unset. Each command that may need them now reads both once, in its RunE, into locked buffers on the CLI Instance, and unsets them at once. The buffers are passed down: vault.CreateVault takes the mnemonic, a Vault carries Mnemonic and UnlockPassphrase, and the PGP, keychain and Secure Enclave unlocker constructors take both. Nothing below the command reads the environment. README warns against both variables. Model: opus-5-5
1053 lines
29 KiB
Go
1053 lines
29 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
const (
|
|
// vaultSecretSeparator is the delimiter between vault name and secret name
|
|
vaultSecretSeparator = ":"
|
|
// vaultSecretParts is the number of parts when splitting vault:secret
|
|
vaultSecretParts = 2
|
|
|
|
// initialBufferSize is the starting size for secret read buffers (4KB)
|
|
initialBufferSize = 4 * 1024
|
|
// maxSecretSize is the maximum allowed size of a secret (100MB)
|
|
maxSecretSize = 100 * 1024 * 1024
|
|
)
|
|
|
|
// Sentinel errors for secret operations
|
|
var (
|
|
errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit")
|
|
errSecretFileTooLarge = errors.New(
|
|
"secret file too large: exceeds 100MB limit")
|
|
errSecretNotFound = errors.New("not found")
|
|
errSecretExistsNoForce = errors.New(
|
|
"already exists (use --force to overwrite)")
|
|
errVaultDoesNotExist = errors.New("does not exist")
|
|
errCrossVaultSourceUnqualified = errors.New(
|
|
"source must specify vault (e.g., vault:secret) for cross-vault move")
|
|
errMoveOntoItself = errors.New("cannot be moved onto itself")
|
|
)
|
|
|
|
// bufferInfo tracks a protected buffer and the number of bytes used in it
|
|
type bufferInfo struct {
|
|
buffer *memguard.LockedBuffer
|
|
used int
|
|
}
|
|
|
|
// ParseVaultSecretRef parses a "vault:secret" or just "secret" reference
|
|
// Returns (vaultName, secretName, isQualified)
|
|
// If no vault is specified, returns empty vaultName and isQualified=false
|
|
func ParseVaultSecretRef(ref string) (string, string, bool) {
|
|
parts := strings.SplitN(ref, vaultSecretSeparator, vaultSecretParts)
|
|
if len(parts) == vaultSecretParts {
|
|
return parts[0], parts[1], true
|
|
}
|
|
|
|
return "", ref, false
|
|
}
|
|
|
|
func newAddCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "add <secret-name>",
|
|
Short: "Add a secret to the vault",
|
|
Long: `Add a secret to the current vault. The secret value is read from stdin.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
secret.Debug("Add command RunE starting", "secret_name", args[0])
|
|
|
|
force, _ := cmd.Flags().GetBool("force")
|
|
secret.Debug("Got force flag", "force", force)
|
|
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
|
}
|
|
|
|
cli.cmd = cmd // Set the command for stdin access
|
|
|
|
destroySecrets := cli.readSecretEnv()
|
|
defer destroySecrets()
|
|
|
|
secret.Debug("Created CLI instance, calling AddSecret")
|
|
|
|
return cli.AddSecret(args[0], force)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func newGetCmd() *cobra.Command {
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
log.Fatalf("failed to initialize CLI: %v", err)
|
|
}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "get <secret-name>",
|
|
Short: "Retrieve a secret from the vault",
|
|
Args: cobra.ExactArgs(1),
|
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
version, _ := cmd.Flags().GetString("version")
|
|
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
|
}
|
|
|
|
destroySecrets := cli.readSecretEnv()
|
|
defer destroySecrets()
|
|
|
|
// Without --version, get the current version. A given
|
|
// --version is checked as typed, so an empty one is rejected.
|
|
if !cmd.Flags().Changed("version") {
|
|
return cli.GetSecret(cmd, args[0])
|
|
}
|
|
|
|
return cli.GetSecretWithVersion(cmd, args[0], version)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringP("version", "v", "", "Get a specific version (default: current)")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func newListCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "list [filter]",
|
|
Aliases: []string{"ls"},
|
|
Short: "List all secrets in the current vault",
|
|
Long: `List all secrets in the current vault. Optionally filter ` +
|
|
`by substring match in secret name.`,
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
jsonOutput, _ := cmd.Flags().GetBool("json")
|
|
quietOutput, _ := cmd.Flags().GetBool("quiet")
|
|
|
|
var filter string
|
|
if len(args) > 0 {
|
|
filter = args[0]
|
|
}
|
|
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
|
}
|
|
|
|
return cli.ListSecrets(cmd, jsonOutput, quietOutput, filter)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().Bool("json", false, "Output in JSON format")
|
|
cmd.Flags().BoolP("quiet", "q", false, "Output only secret names (for scripting)")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func newImportCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "import <secret-name>",
|
|
Short: "Import a secret from a file",
|
|
Long: `Import a secret from a file and store it in the current ` +
|
|
`vault under the given name.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
sourceFile, _ := cmd.Flags().GetString("source")
|
|
force, _ := cmd.Flags().GetBool("force")
|
|
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
|
}
|
|
|
|
destroySecrets := cli.readSecretEnv()
|
|
defer destroySecrets()
|
|
|
|
return cli.ImportSecret(cmd, args[0], sourceFile, force)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringP("source", "s", "", "Source file to import from (required)")
|
|
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
|
_ = cmd.MarkFlagRequired("source")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func newRemoveCmd() *cobra.Command {
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
log.Fatalf("failed to initialize CLI: %v", err)
|
|
}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "remove <secret-name>",
|
|
Aliases: []string{"rm"},
|
|
Short: "Remove a secret from the vault",
|
|
Long: `Remove a secret and all its versions from the current ` +
|
|
`vault. This action is permanent and cannot be undone.`,
|
|
Args: cobra.ExactArgs(1),
|
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
|
}
|
|
|
|
return cli.RemoveSecret(cmd, args[0], false)
|
|
},
|
|
}
|
|
|
|
return cmd
|
|
}
|
|
|
|
func newMoveCmd() *cobra.Command {
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
log.Fatalf("failed to initialize CLI: %v", err)
|
|
}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "move <source> <destination>",
|
|
Aliases: []string{"mv", "rename"},
|
|
Short: "Move or rename a secret",
|
|
Long: `Move a secret within a vault or between vaults.
|
|
|
|
For within-vault moves (rename):
|
|
secret move old-name new-name
|
|
|
|
For cross-vault moves:
|
|
secret move source-vault:secret-name dest-vault
|
|
secret move source-vault:secret-name dest-vault:new-name
|
|
|
|
Cross-vault moves copy ALL versions of the secret, preserving history.
|
|
The source secret is deleted after successful copy.`,
|
|
Args: cobra.ExactArgs(2), //nolint:mnd // source and destination args
|
|
ValidArgsFunction: func(
|
|
cmd *cobra.Command, args []string, toComplete string,
|
|
) ([]string, cobra.ShellCompDirective) {
|
|
// Complete vault:secret format
|
|
return getVaultSecretCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
|
},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
force, _ := cmd.Flags().GetBool("force")
|
|
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
|
}
|
|
|
|
destroySecrets := cli.readSecretEnv()
|
|
defer destroySecrets()
|
|
|
|
return cli.MoveSecret(cmd, args[0], args[1], force)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolP("force", "f", false,
|
|
"Overwrite if destination secret already exists")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// updateBufferSize updates the buffer size based on usage pattern
|
|
func updateBufferSize(currentSize int, sameSize *int) int {
|
|
const (
|
|
doubleAfterBuffers = 2
|
|
growthFactor = 2
|
|
)
|
|
|
|
*sameSize++
|
|
if *sameSize >= doubleAfterBuffers {
|
|
*sameSize = 0
|
|
|
|
return currentSize * growthFactor
|
|
}
|
|
|
|
return currentSize
|
|
}
|
|
|
|
// destroyBuffers destroys every buffer in the list
|
|
func destroyBuffers(buffers []bufferInfo) {
|
|
for _, b := range buffers {
|
|
b.buffer.Destroy()
|
|
}
|
|
}
|
|
|
|
// readSecretFromReader reads all data from reader into protected buffers,
|
|
// enforcing the maximum secret size. On failure the accumulated buffers
|
|
// are destroyed; on success the caller must destroy them.
|
|
func readSecretFromReader(reader io.Reader) ([]bufferInfo, int, error) {
|
|
var buffers []bufferInfo
|
|
|
|
totalSize := 0
|
|
currentBufferSize := initialBufferSize
|
|
sameSize := 0
|
|
|
|
for {
|
|
// Create a new buffer
|
|
buffer := memguard.NewBuffer(currentBufferSize)
|
|
n, err := io.ReadFull(reader, buffer.Bytes())
|
|
|
|
if n == 0 {
|
|
// No data read, destroy the unused buffer
|
|
buffer.Destroy()
|
|
} else {
|
|
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
|
totalSize += n
|
|
|
|
if totalSize > maxSecretSize {
|
|
destroyBuffers(buffers)
|
|
|
|
return nil, 0, errSecretTooLarge
|
|
}
|
|
|
|
// If we filled the buffer, consider growing for next iteration
|
|
if n == currentBufferSize {
|
|
currentBufferSize = updateBufferSize(currentBufferSize, &sameSize)
|
|
}
|
|
}
|
|
|
|
if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) {
|
|
break
|
|
} else if err != nil {
|
|
destroyBuffers(buffers)
|
|
|
|
return nil, 0, err
|
|
}
|
|
}
|
|
|
|
return buffers, totalSize, nil
|
|
}
|
|
|
|
// combineBuffers copies the used portions of buffers into a single
|
|
// protected buffer of totalSize bytes
|
|
func combineBuffers(buffers []bufferInfo, totalSize int) *memguard.LockedBuffer {
|
|
valueBuffer := memguard.NewBuffer(totalSize)
|
|
|
|
offset := 0
|
|
for _, b := range buffers {
|
|
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
|
offset += b.used
|
|
}
|
|
|
|
return valueBuffer
|
|
}
|
|
|
|
// AddSecret adds a secret to the current vault
|
|
func (cli *Instance) AddSecret(secretName string, force bool) error {
|
|
secret.Debug("CLI AddSecret starting", "secret_name", secretName, "force", force)
|
|
|
|
// Get current vault
|
|
secret.Debug("Getting current vault")
|
|
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
|
|
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
|
|
|
// Read secret value directly into protected buffers
|
|
secret.Debug("Reading secret value from stdin into protected buffers")
|
|
|
|
buffers, totalSize, err := readSecretFromReader(cli.cmd.InOrStdin())
|
|
if err != nil {
|
|
if errors.Is(err, errSecretTooLarge) {
|
|
return err
|
|
}
|
|
|
|
return fmt.Errorf("failed to read secret value: %w", err)
|
|
}
|
|
defer destroyBuffers(buffers)
|
|
|
|
// Check for trailing newline in the last buffer
|
|
if len(buffers) > 0 && totalSize > 0 {
|
|
lastBuffer := &buffers[len(buffers)-1]
|
|
if lastBuffer.buffer.Bytes()[lastBuffer.used-1] == '\n' {
|
|
lastBuffer.used--
|
|
totalSize--
|
|
}
|
|
}
|
|
|
|
secret.Debug("Read secret value from stdin",
|
|
"value_length", totalSize, "buffers", len(buffers))
|
|
|
|
// Combine all buffers into a single protected buffer
|
|
valueBuffer := combineBuffers(buffers, totalSize)
|
|
defer valueBuffer.Destroy()
|
|
|
|
// Locked only now that stdin has been read: in `secret encrypt key |
|
|
// secret add name`, holding the lock while reading would leave each
|
|
// command waiting for the other.
|
|
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release()
|
|
|
|
// Add the secret to the vault
|
|
secret.Debug("Calling vault.AddSecret", "secret_name", secretName,
|
|
"value_length", valueBuffer.Size(), "force", force)
|
|
|
|
err = vlt.AddSecret(secretName, valueBuffer, force)
|
|
if err != nil {
|
|
secret.Debug("vault.AddSecret failed", "error", err)
|
|
|
|
return err
|
|
}
|
|
|
|
secret.Debug("vault.AddSecret completed successfully")
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetSecret retrieves and prints the current version of a secret
|
|
func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
|
|
secret.Debug("GetSecret called", "secretName", secretName)
|
|
|
|
// Get current vault
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
|
|
value, err := vlt.GetSecret(secretName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer value.Destroy()
|
|
|
|
// Write the value straight from locked memory, with no trailing newline
|
|
_, _ = cmd.OutOrStdout().Write(value.Bytes())
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetSecretWithVersion retrieves and prints a specific version of a secret.
|
|
// The version must be one of the secret's versions.
|
|
func (cli *Instance) GetSecretWithVersion(
|
|
cmd *cobra.Command, secretName string, version string,
|
|
) error {
|
|
secret.Debug("GetSecretWithVersion called",
|
|
"secretName", secretName, "version", version)
|
|
|
|
// Get current vault
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
secret.Debug("Failed to get current vault", "error", err)
|
|
|
|
return err
|
|
}
|
|
|
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
|
|
// Get the secret value
|
|
value, err := vlt.GetSecretVersion(secretName, version)
|
|
if err != nil {
|
|
secret.Debug("Failed to get secret", "error", err)
|
|
|
|
return err
|
|
}
|
|
defer value.Destroy()
|
|
|
|
secret.Debug("Got secret value", "valueLength", value.Size())
|
|
|
|
// Write the value straight from locked memory, with no trailing newline
|
|
_, _ = cmd.OutOrStdout().Write(value.Bytes())
|
|
|
|
secret.Debug("Printed value to stdout")
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListSecrets lists all secrets in the current vault
|
|
func (cli *Instance) ListSecrets(
|
|
cmd *cobra.Command, jsonOutput bool, quietOutput bool, filter string,
|
|
) error {
|
|
// Get current vault
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get list of secrets
|
|
secrets, err := vlt.ListSecrets()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to list secrets: %w", err)
|
|
}
|
|
|
|
// Filter secrets if filter is provided
|
|
var filteredSecrets []string
|
|
|
|
if filter != "" {
|
|
for _, secretName := range secrets {
|
|
if strings.Contains(secretName, filter) {
|
|
filteredSecrets = append(filteredSecrets, secretName)
|
|
}
|
|
}
|
|
} else {
|
|
filteredSecrets = secrets
|
|
}
|
|
|
|
switch {
|
|
case jsonOutput:
|
|
return printSecretsJSON(cmd, vlt, filteredSecrets, filter)
|
|
case quietOutput:
|
|
// Quiet output - just secret names
|
|
for _, secretName := range filteredSecrets {
|
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), secretName)
|
|
}
|
|
|
|
return nil
|
|
default:
|
|
return printSecretsTable(cmd, vlt, filteredSecrets, filter, len(secrets))
|
|
}
|
|
}
|
|
|
|
// printSecretsJSON prints the filtered secrets with metadata as JSON
|
|
func printSecretsJSON(
|
|
cmd *cobra.Command, vlt *vault.Vault, filteredSecrets []string, filter string,
|
|
) error {
|
|
// For JSON output, get metadata for each secret
|
|
secretsWithMetadata := make([]map[string]any, 0, len(filteredSecrets))
|
|
|
|
for _, secretName := range filteredSecrets {
|
|
secretInfo := map[string]any{
|
|
"name": secretName,
|
|
}
|
|
|
|
// Try to get metadata using GetSecretObject
|
|
secretObj, err := vlt.GetSecretObject(secretName)
|
|
if err == nil {
|
|
metadata := secretObj.GetMetadata()
|
|
secretInfo["created_at"] = metadata.CreatedAt
|
|
secretInfo["updated_at"] = metadata.UpdatedAt
|
|
}
|
|
|
|
secretsWithMetadata = append(secretsWithMetadata, secretInfo)
|
|
}
|
|
|
|
output := map[string]any{
|
|
"secrets": secretsWithMetadata,
|
|
}
|
|
if filter != "" {
|
|
output["filter"] = filter
|
|
}
|
|
|
|
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal JSON: %w", err)
|
|
}
|
|
|
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes))
|
|
|
|
return nil
|
|
}
|
|
|
|
// printSecretsTable prints the filtered secrets as a formatted table
|
|
func printSecretsTable(
|
|
cmd *cobra.Command, vlt *vault.Vault,
|
|
filteredSecrets []string, filter string, totalCount int,
|
|
) error {
|
|
// Pretty table output
|
|
out := cmd.OutOrStdout()
|
|
|
|
if len(filteredSecrets) == 0 {
|
|
if filter != "" {
|
|
_, _ = fmt.Fprintf(out,
|
|
"No secrets found in vault '%s' matching filter '%s'.\n",
|
|
vlt.GetName(), filter)
|
|
} else {
|
|
_, _ = fmt.Fprintln(out, "No secrets found in current vault.")
|
|
_, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Get current vault name for display
|
|
if filter != "" {
|
|
_, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n",
|
|
vlt.GetName(), filter)
|
|
} else {
|
|
_, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName())
|
|
}
|
|
|
|
// Calculate the maximum name length for proper column alignment
|
|
maxNameLen := len("NAME") // Start with header length
|
|
for _, secretName := range filteredSecrets {
|
|
if len(secretName) > maxNameLen {
|
|
maxNameLen = len(secretName)
|
|
}
|
|
}
|
|
// Add some padding
|
|
maxNameLen += 2
|
|
|
|
// Print headers with dynamic width
|
|
nameFormat := fmt.Sprintf("%%-%ds", maxNameLen)
|
|
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", "NAME", "LAST UPDATED")
|
|
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n",
|
|
strings.Repeat("-", len("NAME")), "------------")
|
|
|
|
for _, secretName := range filteredSecrets {
|
|
lastUpdated := "unknown"
|
|
|
|
secretObj, err := vlt.GetSecretObject(secretName)
|
|
if err == nil {
|
|
metadata := secretObj.GetMetadata()
|
|
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated)
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets))
|
|
if filter != "" {
|
|
_, _ = fmt.Fprintf(out, " (filtered from %d)", totalCount)
|
|
}
|
|
|
|
_, _ = fmt.Fprintln(out)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ImportSecret imports a secret from a file
|
|
func (cli *Instance) ImportSecret(
|
|
cmd *cobra.Command, secretName, sourceFile string, force bool,
|
|
) error {
|
|
err := vault.ValidateSecretName(secretName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get current vault
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
|
|
// Read secret value from the source file into protected buffers
|
|
file, err := cli.fs.Open(sourceFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open file %s: %w", sourceFile, err)
|
|
}
|
|
defer func() {
|
|
closeErr := file.Close()
|
|
if closeErr != nil {
|
|
secret.Warn("Failed to close file", "error", closeErr)
|
|
}
|
|
}()
|
|
|
|
buffers, totalSize, err := readSecretFromReader(file)
|
|
if err != nil {
|
|
if errors.Is(err, errSecretTooLarge) {
|
|
return errSecretFileTooLarge
|
|
}
|
|
|
|
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
|
}
|
|
defer destroyBuffers(buffers)
|
|
|
|
// Combine all buffers into a single protected buffer
|
|
valueBuffer := combineBuffers(buffers, totalSize)
|
|
defer valueBuffer.Destroy()
|
|
|
|
// Locked only now that the file has been read, as in AddSecret: the
|
|
// file may be a pipe written by another secret command.
|
|
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release()
|
|
|
|
// Store the secret in the vault
|
|
err = vlt.AddSecret(secretName, valueBuffer, force)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cmd.Printf("Successfully imported secret '%s' from file '%s'\n",
|
|
secretName, sourceFile)
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemoveSecret removes a secret from the vault
|
|
func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool) error {
|
|
err := vault.ValidateSecretName(secretName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release()
|
|
|
|
// Get current vault
|
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if secret exists
|
|
vaultDir, err := currentVlt.GetDirectory()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
|
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
|
|
|
exists, err := afero.DirExists(cli.fs, secretDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
|
}
|
|
|
|
if !exists {
|
|
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
|
}
|
|
|
|
// Count versions for information
|
|
versionsDir := filepath.Join(secretDir, "versions")
|
|
versionCount := 0
|
|
|
|
entries, err := afero.ReadDir(cli.fs, versionsDir)
|
|
if err == nil {
|
|
versionCount = len(entries)
|
|
}
|
|
|
|
// Remove the secret directory
|
|
err = secret.RemoveDirAtomic(cli.fs, secretDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove secret: %w", err)
|
|
}
|
|
|
|
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n",
|
|
secretName, versionCount)
|
|
|
|
return nil
|
|
}
|
|
|
|
// MoveSecret moves or renames a secret (within or across vaults), holding
|
|
// the state directory lock.
|
|
func (cli *Instance) MoveSecret(
|
|
cmd *cobra.Command, source, dest string, force bool,
|
|
) error {
|
|
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release()
|
|
|
|
return cli.moveSecret(cmd, source, dest, force)
|
|
}
|
|
|
|
// moveSecret does the work of MoveSecret. The caller holds the state
|
|
// directory lock.
|
|
func (cli *Instance) moveSecret(
|
|
cmd *cobra.Command, source, dest string, force bool,
|
|
) error {
|
|
// Parse source and destination
|
|
srcVaultName, srcSecretName, srcQualified := ParseVaultSecretRef(source)
|
|
destVaultName, destSecretName, destQualified := ParseVaultSecretRef(dest)
|
|
|
|
// Cross-vault move requires source to be qualified
|
|
if !srcQualified && destQualified {
|
|
return errCrossVaultSourceUnqualified
|
|
}
|
|
|
|
// If destination is not qualified (no colon), check if it's a vault name
|
|
// Format: "work:secret default" means move to vault "default"
|
|
// Format: "work:secret default:newname" means move to vault "default"
|
|
// with a new name
|
|
if srcQualified && !destQualified {
|
|
// Check if dest is actually a vault name
|
|
_, err := cli.existingVault(dest)
|
|
if err == nil {
|
|
// dest is a vault name, use source secret name
|
|
destVaultName = dest
|
|
destSecretName = srcSecretName
|
|
} else {
|
|
// dest is a secret name in source vault
|
|
destVaultName = srcVaultName
|
|
}
|
|
}
|
|
|
|
// If destination secret name is empty, use source secret name. A plain
|
|
// rename keeps it empty, so that the check below rejects it.
|
|
if srcQualified && destSecretName == "" {
|
|
destSecretName = srcSecretName
|
|
}
|
|
|
|
// Check both names, for every form of the move, before building any path
|
|
// from them.
|
|
err := vault.ValidateSecretName(srcSecretName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = vault.ValidateSecretName(destSecretName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Neither name is qualified: a rename within the current vault.
|
|
if !srcQualified {
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return cli.moveSecretWithinVault(
|
|
cmd, vlt, srcSecretName, destSecretName, force)
|
|
}
|
|
|
|
// Both vault names must be valid and name existing vaults exactly, so
|
|
// that two spellings of one vault, such as "work" and "work/", are never
|
|
// taken for two vaults. A named vault does not become the current vault.
|
|
srcVault, err := cli.existingVault(srcVaultName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
destVault, err := cli.existingVault(destVaultName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if srcVaultName == destVaultName {
|
|
return cli.moveSecretWithinVault(
|
|
cmd, srcVault, srcSecretName, destSecretName, force)
|
|
}
|
|
|
|
return cli.moveSecretCrossVault(
|
|
cmd, srcVault, srcSecretName, destVault, destSecretName, force)
|
|
}
|
|
|
|
// existingVault returns the vault with the given name, or an error if the
|
|
// name is not a valid vault name or there is no such vault. Unlike
|
|
// vault.SelectVault, it leaves the current vault as it is.
|
|
func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
|
|
err := vault.ValidateVaultName(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list vaults: %w", err)
|
|
}
|
|
|
|
if !slices.Contains(vaults, name) {
|
|
return nil, fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
|
|
}
|
|
|
|
return vault.NewVault(cli.fs, cli.stateDir, name), nil
|
|
}
|
|
|
|
// moveSecretWithinVault renames a secret within the vault vlt. Its caller,
|
|
// MoveSecret, has already checked both secret names.
|
|
func (cli *Instance) moveSecretWithinVault(
|
|
cmd *cobra.Command, vlt *vault.Vault, source, dest string, force bool,
|
|
) error {
|
|
// With --force the destination is removed before the source is renamed
|
|
// onto it, which would delete the secret.
|
|
if source == dest {
|
|
return fmt.Errorf("secret '%s' %w", source, errMoveOntoItself)
|
|
}
|
|
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sourceEncoded := strings.ReplaceAll(source, "/", "%")
|
|
sourceDir := filepath.Join(vaultDir, "secrets.d", sourceEncoded)
|
|
|
|
exists, err := afero.DirExists(cli.fs, sourceDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if source secret exists: %w", err)
|
|
}
|
|
|
|
if !exists {
|
|
return fmt.Errorf("secret '%s' %w", source, errSecretNotFound)
|
|
}
|
|
|
|
destEncoded := strings.ReplaceAll(dest, "/", "%")
|
|
destDir := filepath.Join(vaultDir, "secrets.d", destEncoded)
|
|
|
|
// Removing a destination that is the source under another name, such as
|
|
// "foo" for "Foo" on a case-insensitive filesystem, would delete it too.
|
|
same, err := cli.sameDirectory(sourceDir, destDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if same {
|
|
return fmt.Errorf("secret '%s' %w: '%s' is the same secret on "+
|
|
"this filesystem", source, errMoveOntoItself, dest)
|
|
}
|
|
|
|
exists, err = afero.DirExists(cli.fs, destDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if destination secret exists: %w", err)
|
|
}
|
|
|
|
if exists {
|
|
if !force {
|
|
return fmt.Errorf("secret '%s' %w", dest, errSecretExistsNoForce)
|
|
}
|
|
|
|
err = secret.RemoveDirAtomic(cli.fs, destDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove existing destination: %w", err)
|
|
}
|
|
}
|
|
|
|
err = cli.fs.Rename(sourceDir, destDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to move secret: %w", err)
|
|
}
|
|
|
|
cmd.Printf("Moved secret '%s' to '%s'\n", source, dest)
|
|
|
|
return nil
|
|
}
|
|
|
|
// sameDirectory reports whether the existing directory dir and the path
|
|
// other are one directory under two names, as secrets.d/Foo and
|
|
// secrets.d/foo are on a case-insensitive filesystem, or a directory and a
|
|
// symbolic link to it. Removing other to make room for dir would then delete
|
|
// dir. It is false if other does not exist, and always false on the
|
|
// in-memory filesystem, which has no such aliasing and whose files
|
|
// os.SameFile does not compare.
|
|
func (cli *Instance) sameDirectory(dir, other string) (bool, error) {
|
|
dirInfo, err := cli.fs.Stat(dir)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to check %s: %w", dir, err)
|
|
}
|
|
|
|
otherInfo, err := cli.fs.Stat(other)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to check %s: %w", other, err)
|
|
}
|
|
|
|
return os.SameFile(dirInfo, otherInfo), nil
|
|
}
|
|
|
|
// moveSecretCrossVault handles moving between two different vaults. Its
|
|
// caller, MoveSecret, has already checked both secret names and that both
|
|
// vaults exist.
|
|
func (cli *Instance) moveSecretCrossVault(
|
|
cmd *cobra.Command,
|
|
srcVault *vault.Vault, srcSecretName string,
|
|
destVault *vault.Vault, destSecretName string,
|
|
force bool,
|
|
) error {
|
|
srcVaultDir, err := srcVault.GetDirectory()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get source vault directory: %w", err)
|
|
}
|
|
|
|
// Verify source secret exists
|
|
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
|
|
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
|
|
|
|
exists, err := afero.DirExists(cli.fs, srcSecretDir)
|
|
if err != nil || !exists {
|
|
return fmt.Errorf("secret '%s' %w in vault '%s'",
|
|
srcSecretName, errSecretNotFound, srcVault.Name)
|
|
}
|
|
|
|
// The source is removed after the copy, so a destination that is the
|
|
// source under another name would be lost with it.
|
|
destVaultDir, err := destVault.GetDirectory()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
|
}
|
|
|
|
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
|
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
|
|
|
same, err := cli.sameDirectory(srcSecretDir, destSecretDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if same {
|
|
return fmt.Errorf("secret '%s:%s' %w: '%s:%s' is the same secret on "+
|
|
"this filesystem", srcVault.Name, srcSecretName, errMoveOntoItself,
|
|
destVault.Name, destSecretName)
|
|
}
|
|
|
|
// Copying needs the long-term keys of both vaults
|
|
srcVault.Mnemonic, srcVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
destVault.Mnemonic, destVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
|
|
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
|
_, err = destVault.GetOrDeriveLongTermKey()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to unlock destination vault '%s': %w", destVault.Name, err)
|
|
}
|
|
|
|
// Count versions for user feedback
|
|
versions, _ := secret.ListVersions(cli.fs, srcSecretDir)
|
|
versionCount := len(versions)
|
|
|
|
// Copy all versions
|
|
err = destVault.CopySecretAllVersions(
|
|
srcVault, srcSecretName, destSecretName, force)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Delete source secret
|
|
err = secret.RemoveDirAtomic(cli.fs, srcSecretDir)
|
|
if err != nil {
|
|
// Copy succeeded but delete failed - warn but don't fail
|
|
cmd.Printf("Warning: copied secret but failed to remove source: %v\n", err)
|
|
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
|
srcVault.Name, srcSecretName, destVault.Name, destSecretName, versionCount)
|
|
|
|
return nil
|
|
}
|
|
|
|
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
|
srcVault.Name, srcSecretName, destVault.Name, destSecretName, versionCount)
|
|
|
|
return nil
|
|
}
|