diff --git a/cmd/cloudstic/cmd_store.go b/cmd/cloudstic/cmd_store.go index 2325463..bee80b2 100644 --- a/cmd/cloudstic/cmd_store.go +++ b/cmd/cloudstic/cmd_store.go @@ -5,12 +5,17 @@ import ( "errors" "flag" "fmt" + "io" "os" "regexp" + "runtime" "sort" "strings" cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/secretref" + "github.com/cloudstic/cli/internal/ui" + "github.com/cloudstic/cli/pkg/store" ) var validRefName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) @@ -19,7 +24,7 @@ func (r *runner) runStore() int { if len(os.Args) < 3 { _, _ = fmt.Fprintln(r.errOut, "Usage: cloudstic store [options]") _, _ = fmt.Fprintln(r.errOut, "") - _, _ = fmt.Fprintln(r.errOut, "Available subcommands: list, show, new") + _, _ = fmt.Fprintln(r.errOut, "Available subcommands: list, show, new, verify") return 1 } @@ -30,6 +35,8 @@ func (r *runner) runStore() int { return r.runStoreShow() case "new": return r.runStoreNew() + case "verify": + return r.runStoreVerify() default: return r.fail("Unknown store subcommand: %s", os.Args[2]) } @@ -195,13 +202,20 @@ func (r *runner) runStoreNew() int { s3Endpoint := fs.String("s3-endpoint", "", "S3-compatible endpoint URL") s3AccessKey := fs.String("s3-access-key", "", "S3 static access key") s3SecretKey := fs.String("s3-secret-key", "", "S3 static secret key") + s3AccessKeySecret := fs.String("s3-access-key-secret", "", "Secret reference for S3 access key (e.g. env://..., keychain://...)") + s3SecretKeySecret := fs.String("s3-secret-key-secret", "", "Secret reference for S3 secret key (e.g. env://..., keychain://...)") s3AccessKeyEnv := fs.String("s3-access-key-env", "", "Env var name for S3 access key") s3SecretKeyEnv := fs.String("s3-secret-key-env", "", "Env var name for S3 secret key") s3ProfileEnv := fs.String("s3-profile-env", "", "Env var name for AWS profile") sftpPassword := fs.String("store-sftp-password", "", "SFTP password") sftpKey := fs.String("store-sftp-key", "", "Path to SFTP private key") + sftpPasswordSecret := fs.String("store-sftp-password-secret", "", "Secret reference for SFTP password (e.g. env://..., keychain://...)") + sftpKeySecret := fs.String("store-sftp-key-secret", "", "Secret reference for SFTP key path (e.g. env://..., keychain://...)") sftpPasswordEnv := fs.String("store-sftp-password-env", "", "Env var name for SFTP password") sftpKeyEnv := fs.String("store-sftp-key-env", "", "Env var name for SFTP key path") + passwordSecret := fs.String("password-secret", "", "Secret reference for repository password (e.g. env://..., keychain://...)") + encryptionKeySecret := fs.String("encryption-key-secret", "", "Secret reference for platform key (e.g. env://..., keychain://...)") + recoveryKeySecret := fs.String("recovery-key-secret", "", "Secret reference for recovery key mnemonic (e.g. env://..., keychain://...)") passwordEnv := fs.String("password-env", "", "Env var name for repository password") encryptionKeyEnv := fs.String("encryption-key-env", "", "Env var name for platform key (hex)") recoveryKeyEnv := fs.String("recovery-key-env", "", "Env var name for recovery key mnemonic") @@ -210,6 +224,9 @@ func (r *runner) runStoreNew() int { kmsEndpoint := fs.String("kms-endpoint", "", "Custom AWS KMS endpoint URL") _ = fs.Parse(reorderArgs(fs, os.Args[3:])) + flagsSet := map[string]bool{} + fs.Visit(func(f *flag.Flag) { flagsSet[f.Name] = true }) + if *name == "" { if r.canPrompt() { v, err := r.promptLine("Store reference name", "") @@ -225,9 +242,89 @@ func (r *runner) runStoreNew() int { if !validRefName.MatchString(*name) { return r.fail("invalid store name %q: must start with a letter or digit and contain only letters, digits, dots, hyphens, or underscores", *name) } - if *uri == "" { + cfg, err := cloudstic.LoadProfilesFile(*profilesFile) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + cfg = &cloudstic.ProfilesConfig{Version: 1} + } else { + return r.fail("Failed to load profiles: %v", err) + } + } + if cfg.Stores == nil { + cfg.Stores = map[string]cloudstic.ProfileStore{} + } + + _, existedBefore := cfg.Stores[*name] + forcePromptURI := false + forcePromptEncryption := false + askKeepEncryption := false + if existing, ok := cfg.Stores[*name]; ok { + if !flagsSet["uri"] && existing.URI != "" { + *uri = existing.URI + } + if !flagsSet["s3-region"] && existing.S3Region != "" { + *s3Region = existing.S3Region + } + if !flagsSet["s3-profile"] && existing.S3Profile != "" { + *s3Profile = existing.S3Profile + } + if !flagsSet["s3-endpoint"] && existing.S3Endpoint != "" { + *s3Endpoint = existing.S3Endpoint + } + if !flagsSet["s3-access-key"] && existing.S3AccessKey != "" { + *s3AccessKey = existing.S3AccessKey + } + if !flagsSet["s3-secret-key"] && existing.S3SecretKey != "" { + *s3SecretKey = existing.S3SecretKey + } + if !flagsSet["s3-access-key-secret"] && !flagsSet["s3-access-key-env"] { + *s3AccessKeySecret = firstNonEmpty(existing.S3AccessKeySecret, envRef(existing.S3AccessKeyEnv)) + } + if !flagsSet["s3-secret-key-secret"] && !flagsSet["s3-secret-key-env"] { + *s3SecretKeySecret = firstNonEmpty(existing.S3SecretKeySecret, envRef(existing.S3SecretKeyEnv)) + } + if !flagsSet["s3-profile-env"] && existing.S3ProfileEnv != "" { + *s3ProfileEnv = existing.S3ProfileEnv + } + if !flagsSet["store-sftp-password"] && existing.StoreSFTPPassword != "" { + *sftpPassword = existing.StoreSFTPPassword + } + if !flagsSet["store-sftp-key"] && existing.StoreSFTPKey != "" { + *sftpKey = existing.StoreSFTPKey + } + if !flagsSet["store-sftp-password-secret"] && !flagsSet["store-sftp-password-env"] { + *sftpPasswordSecret = firstNonEmpty(existing.StoreSFTPPasswordSecret, envRef(existing.StoreSFTPPasswordEnv)) + } + if !flagsSet["store-sftp-key-secret"] && !flagsSet["store-sftp-key-env"] { + *sftpKeySecret = firstNonEmpty(existing.StoreSFTPKeySecret, envRef(existing.StoreSFTPKeyEnv)) + } + if !flagsSet["password-secret"] && !flagsSet["password-env"] { + *passwordSecret = firstNonEmpty(existing.PasswordSecret, envRef(existing.PasswordEnv)) + } + if !flagsSet["encryption-key-secret"] && !flagsSet["encryption-key-env"] { + *encryptionKeySecret = firstNonEmpty(existing.EncryptionKeySecret, envRef(existing.EncryptionKeyEnv)) + } + if !flagsSet["recovery-key-secret"] && !flagsSet["recovery-key-env"] { + *recoveryKeySecret = firstNonEmpty(existing.RecoveryKeySecret, envRef(existing.RecoveryKeyEnv)) + } + if !flagsSet["kms-key-arn"] && existing.KMSKeyARN != "" { + *kmsKeyARN = existing.KMSKeyARN + } + if !flagsSet["kms-region"] && existing.KMSRegion != "" { + *kmsRegion = existing.KMSRegion + } + if !flagsSet["kms-endpoint"] && existing.KMSEndpoint != "" { + *kmsEndpoint = existing.KMSEndpoint + } + if promptURI, askKeep := existingStoreInteractivePlan(r.canPrompt(), hasStoreNewOverrideFlags(flagsSet), storeHasExplicitEncryption(existing)); promptURI { + forcePromptURI = true + askKeepEncryption = askKeep + } + } + + if *uri == "" || forcePromptURI { if r.canPrompt() { - v, err := r.promptLine("Store URI", "") + v, err := r.promptLine("Store URI", *uri) if err != nil { return r.fail("Failed to read store URI: %v", err) } @@ -243,18 +340,6 @@ func (r *runner) runStoreNew() int { return r.fail("%v", err) } - cfg, err := cloudstic.LoadProfilesFile(*profilesFile) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - cfg = &cloudstic.ProfilesConfig{Version: 1} - } else { - return r.fail("Failed to load profiles: %v", err) - } - } - if cfg.Stores == nil { - cfg.Stores = map[string]cloudstic.ProfileStore{} - } - cfg.Stores[*name] = cloudstic.ProfileStore{ URI: *uri, S3Region: *s3Region, @@ -264,21 +349,21 @@ func (r *runner) runStoreNew() int { S3SecretKey: *s3SecretKey, S3AccessKeyEnv: "", S3SecretKeyEnv: "", - S3AccessKeySecret: envRef(*s3AccessKeyEnv), - S3SecretKeySecret: envRef(*s3SecretKeyEnv), + S3AccessKeySecret: firstNonEmpty(*s3AccessKeySecret, envRef(*s3AccessKeyEnv)), + S3SecretKeySecret: firstNonEmpty(*s3SecretKeySecret, envRef(*s3SecretKeyEnv)), S3ProfileEnv: *s3ProfileEnv, StoreSFTPPassword: *sftpPassword, StoreSFTPKey: *sftpKey, StoreSFTPPasswordEnv: "", StoreSFTPKeyEnv: "", - StoreSFTPPasswordSecret: envRef(*sftpPasswordEnv), - StoreSFTPKeySecret: envRef(*sftpKeyEnv), + StoreSFTPPasswordSecret: firstNonEmpty(*sftpPasswordSecret, envRef(*sftpPasswordEnv)), + StoreSFTPKeySecret: firstNonEmpty(*sftpKeySecret, envRef(*sftpKeyEnv)), PasswordEnv: "", EncryptionKeyEnv: "", RecoveryKeyEnv: "", - PasswordSecret: envRef(*passwordEnv), - EncryptionKeySecret: envRef(*encryptionKeyEnv), - RecoveryKeySecret: envRef(*recoveryKeyEnv), + PasswordSecret: firstNonEmpty(*passwordSecret, envRef(*passwordEnv)), + EncryptionKeySecret: firstNonEmpty(*encryptionKeySecret, envRef(*encryptionKeyEnv)), + RecoveryKeySecret: firstNonEmpty(*recoveryKeySecret, envRef(*recoveryKeyEnv)), KMSKeyARN: *kmsKeyARN, KMSRegion: *kmsRegion, KMSEndpoint: *kmsEndpoint, @@ -292,10 +377,19 @@ func (r *runner) runStoreNew() int { if r.canPrompt() { // If no encryption flags were provided, prompt for encryption config. s := cfg.Stores[*name] - if !storeHasExplicitEncryption(s) { + if askKeepEncryption { + keepCurrent, confirmErr := r.promptConfirm("Keep current encryption settings?", true) + if confirmErr != nil { + return r.fail("Failed to read encryption confirmation: %v", confirmErr) + } + forcePromptEncryption = !keepCurrent + } + if forcePromptEncryption || !storeHasExplicitEncryption(s) { r.promptEncryptionConfig(cfg, *name, *profilesFile) } - r.checkOrInitStore(cfg, *name, *profilesFile) + if err := r.checkOrInitStore(cfg, *name, *profilesFile, true, !existedBefore, true); err != nil { + _, _ = fmt.Fprintf(r.errOut, "%v\n", err) + } } return 0 @@ -306,17 +400,68 @@ func (r *runner) runStoreNew() int { // initialize it. Encryption config should already be saved in profiles.yaml // before calling this. Errors are printed but never cause a non-zero exit— // the store config has already been saved. -func (r *runner) checkOrInitStore(cfg *cloudstic.ProfilesConfig, storeName, profilesFile string) { +func (r *runner) runStoreVerify() int { + fs := flag.NewFlagSet("store verify", flag.ExitOnError) + profilesFile := fs.String("profiles-file", envDefault("CLOUDSTIC_PROFILES_FILE", defaultProfilesPathFallback()), "Path to profiles YAML file") + _ = fs.Parse(reorderArgs(fs, os.Args[3:])) + if fs.NArg() > 1 { + return r.fail("usage: cloudstic store verify [-profiles-file ] ") + } + + name := "" + if fs.NArg() == 1 { + name = fs.Arg(0) + } + + cfg, err := cloudstic.LoadProfilesFile(*profilesFile) + if err != nil { + return r.fail("Failed to load profiles: %v", err) + } + if len(cfg.Stores) == 0 { + return r.fail("No stores configured") + } + + if name == "" { + if !r.canPrompt() { + return r.fail("usage: cloudstic store verify [-profiles-file ] ") + } + names := make([]string, 0, len(cfg.Stores)) + for n := range cfg.Stores { + names = append(names, n) + } + sort.Strings(names) + picked, pickErr := r.promptSelect("Select store", names) + if pickErr != nil { + return r.fail("Failed to select store: %v", pickErr) + } + name = picked + } + + if _, ok := cfg.Stores[name]; !ok { + return r.fail("Unknown store %q", name) + } + if err := r.checkOrInitStore(cfg, name, *profilesFile, false, true, false); err != nil { + return r.fail("%v", err) + } + return 0 +} + +func (r *runner) checkOrInitStore(cfg *cloudstic.ProfilesConfig, storeName, profilesFile string, allowMissingSecrets, warnOnMissingSecrets, offerInit bool) error { s := cfg.Stores[storeName] g, err := globalFlagsFromProfileStore(s) if err != nil { - _, _ = fmt.Fprintf(r.errOut, "Could not resolve store credentials: %v\n", err) - return + if allowMissingSecrets && isSecretNotFoundError(err) { + if warnOnMissingSecrets { + _, _ = fmt.Fprintf(r.errOut, "Store credentials are configured but not currently available: %v\n", err) + _, _ = fmt.Fprintf(r.errOut, "Set required secrets and run: cloudstic store verify %s\n", storeName) + } + return nil + } + return fmt.Errorf("could not resolve store credentials: %w", err) } raw, err := g.initObjectStore() if err != nil { - _, _ = fmt.Fprintf(r.errOut, "Could not connect to store: %v\n", err) - return + return fmt.Errorf("could not connect to store: %w", err) } ctx := context.Background() @@ -325,13 +470,27 @@ func (r *runner) checkOrInitStore(cfg *cloudstic.ProfilesConfig, storeName, prof cfgData, err := raw.Get(ctx, "config") if err == nil && cfgData != nil { _, _ = fmt.Fprintln(r.out, "Store is already initialized and accessible.") - return + repoCfg, cfgErr := cloudstic.LoadRepoConfig(ctx, raw) + if cfgErr != nil { + return fmt.Errorf("read repository config: %w", cfgErr) + } + if repoCfg != nil && repoCfg.Encrypted { + _, _ = fmt.Fprintln(r.out, "Repository is encrypted; verifying configured credentials...") + if err := verifyStoreEncryptionCredentials(ctx, g, raw); err != nil { + return fmt.Errorf("store is initialized, but configured encryption credentials are invalid: %w", err) + } + _, _ = fmt.Fprintln(r.out, "Encryption credentials are valid.") + } + return nil } _, _ = fmt.Fprintln(r.out, "Store is accessible but not yet initialized.") + if !offerInit { + return nil + } yes, promptErr := r.promptConfirm("Initialize it now?", true) if promptErr != nil || !yes { - return + return nil } // Check if the store has encryption config. @@ -341,11 +500,10 @@ func (r *runner) checkOrInitStore(cfg *cloudstic.ProfilesConfig, storeName, prof // No encryption configured — init without encryption. result, initErr := cloudstic.InitRepo(ctx, raw, cloudstic.WithInitNoEncryption()) if initErr != nil { - _, _ = fmt.Fprintf(r.errOut, "Init failed: %v\n", initErr) - return + return fmt.Errorf("init failed: %w", initErr) } r.printInitResult(result) - return + return nil } // Build keychain from the store's encryption settings. @@ -353,18 +511,17 @@ func (r *runner) checkOrInitStore(cfg *cloudstic.ProfilesConfig, storeName, prof // If not set, prompt for the password interactively. kc, err := g.buildKeychain(ctx) if err != nil { - _, _ = fmt.Fprintf(r.errOut, "Failed to build keychain: %v\n", err) - return + return fmt.Errorf("failed to build keychain: %w", err) } var initOpts []cloudstic.InitOption initOpts = append(initOpts, cloudstic.WithInitCredentials(kc)) result, err := cloudstic.InitRepo(ctx, raw, initOpts...) if err != nil { - _, _ = fmt.Fprintf(r.errOut, "Init failed: %v\n", err) - return + return fmt.Errorf("init failed: %w", err) } r.printInitResult(result) + return nil } // promptEncryptionConfig guides the user through encryption configuration @@ -386,59 +543,192 @@ func (r *runner) promptEncryptionConfig(cfg *cloudstic.ProfilesConfig, storeName return } - s := cfg.Stores[storeName] + s, err := configureStoreEncryptionSelection( + cfg.Stores[storeName], + storeName, + picked, + r.promptSecretReference, + r.promptLine, + r.out, + ) + if err != nil { + _, _ = fmt.Fprintf(r.errOut, "%v\n", err) + return + } + if picked == options[3] { + return + } + + // Save updated store config. + cfg.Stores[storeName] = s + if saveErr := cloudstic.SaveProfilesFile(profilesFile, cfg); saveErr != nil { + _, _ = fmt.Fprintf(r.errOut, "Warning: could not save encryption settings: %v\n", saveErr) + } +} +func configureStoreEncryptionSelection( + s cloudstic.ProfileStore, + storeName, picked string, + promptSecretRef func(string, string, string, string) (string, error), + promptLine func(string, string) (string, error), + out io.Writer, +) (cloudstic.ProfileStore, error) { switch picked { - case options[0]: // Password - envName, envErr := r.promptLine("Env var name for the repository password", "CLOUDSTIC_PASSWORD") - if envErr != nil { - _, _ = fmt.Fprintf(r.errOut, "Failed to read env var name: %v\n", envErr) - return - } - if envName == "" { - envName = "CLOUDSTIC_PASSWORD" + case "Password (recommended for interactive use)": + secretRef, err := promptSecretRef(storeName, "repository password", "CLOUDSTIC_PASSWORD", "password") + if err != nil { + return s, fmt.Errorf("failed to configure password secret: %w", err) } s.PasswordEnv = "" - s.PasswordSecret = envRef(envName) - _, _ = fmt.Fprintf(r.out, "Encryption: password via $%s\n", envName) - - case options[1]: // Platform key - envName, envErr := r.promptLine("Env var name for the platform key (64-char hex)", "CLOUDSTIC_ENCRYPTION_KEY") - if envErr != nil { - _, _ = fmt.Fprintf(r.errOut, "Failed to read env var name: %v\n", envErr) - return - } - if envName == "" { - envName = "CLOUDSTIC_ENCRYPTION_KEY" + s.PasswordSecret = secretRef + _, _ = fmt.Fprintf(out, "Encryption: password via %s\n", secretRef) + case "Platform key (recommended for automation/CI)": + secretRef, err := promptSecretRef(storeName, "platform key (64-char hex)", "CLOUDSTIC_ENCRYPTION_KEY", "encryption-key") + if err != nil { + return s, fmt.Errorf("failed to configure platform key secret: %w", err) } s.EncryptionKeyEnv = "" - s.EncryptionKeySecret = envRef(envName) - _, _ = fmt.Fprintf(r.out, "Encryption: platform key via $%s\n", envName) - - case options[2]: // KMS - arn, arnErr := r.promptLine("KMS key ARN", "") - if arnErr != nil || arn == "" { - _, _ = fmt.Fprintln(r.errOut, "KMS key ARN is required.") - return + s.EncryptionKeySecret = secretRef + _, _ = fmt.Fprintf(out, "Encryption: platform key via %s\n", secretRef) + case "AWS KMS key (enterprise)": + arn, err := promptLine("KMS key ARN", "") + if err != nil || arn == "" { + return s, fmt.Errorf("KMS key ARN is required") } s.KMSKeyARN = arn - - region, _ := r.promptLine("KMS region", "us-east-1") + region, _ := promptLine("KMS region", "us-east-1") if region != "" { s.KMSRegion = region } - _, _ = fmt.Fprintf(r.out, "Encryption: AWS KMS (%s)\n", arn) + _, _ = fmt.Fprintf(out, "Encryption: AWS KMS (%s)\n", arn) + case "No encryption (not recommended)": + _, _ = fmt.Fprintln(out, "Encryption: none (not recommended)") + default: + return s, fmt.Errorf("unsupported encryption selection: %s", picked) + } + return s, nil +} - case options[3]: // No encryption - _, _ = fmt.Fprintln(r.out, "Encryption: none (not recommended)") - return +func (r *runner) promptSecretReference(storeName, secretLabel, defaultEnvName, defaultAccount string) (string, error) { + return promptSecretReferenceWithFns( + runtime.GOOS, + storeName, + secretLabel, + defaultEnvName, + defaultAccount, + r.promptSelect, + r.promptLine, + r.promptSecret, + os.LookupEnv, + nativeSecretExists, + saveSecretToNativeStore, + ) +} + +func promptSecretReferenceWithFns( + goos, storeName, secretLabel, defaultEnvName, defaultAccount string, + promptSelect func(string, []string) (string, error), + promptLine func(string, string) (string, error), + promptSecret func(string) (string, error), + lookupEnv func(string) (string, bool), + nativeSecretExists func(context.Context, string, string) (bool, error), + writeNativeSecret func(context.Context, string, string, string) error, +) (string, error) { + keychainRef := func() (string, error) { + service := "cloudstic/store/" + storeName + account := defaultAccount + exists, err := nativeSecretExists(context.Background(), service, account) + if err != nil { + return "", err + } + if exists { + return "keychain://" + service + "/" + account, nil + } + secretValue, err := promptSecret("Secret value") + if err != nil { + return "", err + } + if secretValue == "" { + return "", fmt.Errorf("secret value cannot be empty") + } + if err := writeNativeSecret(context.Background(), service, account, secretValue); err != nil { + return "", err + } + return "keychain://" + service + "/" + account, nil } - // Save updated store config. - cfg.Stores[storeName] = s - if saveErr := cloudstic.SaveProfilesFile(profilesFile, cfg); saveErr != nil { - _, _ = fmt.Fprintf(r.errOut, "Warning: could not save encryption settings: %v\n", saveErr) + if goos == "darwin" { + picked, err := promptSelect( + fmt.Sprintf("Where should %s be stored?", secretLabel), + []string{"Environment variable (env://)", "macOS Keychain (keychain://)"}, + ) + if err != nil { + return "", err + } + if strings.HasPrefix(picked, "macOS Keychain") { + return keychainRef() + } + } + + envName, err := promptLine("Env var name", defaultEnvName) + if err != nil { + return "", err + } + if _, ok := lookupEnv(envName); !ok && goos == "darwin" { + picked, err := promptSelect( + fmt.Sprintf("Environment variable %q is not set in this shell", envName), + []string{"Keep environment variable reference (env://)", "Store in macOS Keychain instead (keychain://)"}, + ) + if err != nil { + return "", err + } + if strings.HasPrefix(picked, "Store in macOS Keychain") { + return keychainRef() + } + } + return envRef(envName), nil +} + +func isSecretNotFoundError(err error) bool { + var refErr *secretref.Error + if errors.As(err, &refErr) { + return refErr.Kind == secretref.KindNotFound + } + return false +} + +func verifyStoreEncryptionCredentials(ctx context.Context, g *globalFlags, raw store.ObjectStore) error { + kc, err := g.buildKeychain(ctx) + if err != nil { + return fmt.Errorf("build keychain: %w", err) + } + _, err = cloudstic.NewClient(raw, + cloudstic.WithKeychain(kc), + cloudstic.WithReporter(ui.NewNoOpReporter()), + ) + if err != nil { + return err + } + return nil +} + +func hasStoreNewOverrideFlags(flagsSet map[string]bool) bool { + for name := range flagsSet { + switch name { + case "name", "profiles-file": + continue + default: + return true + } + } + return false +} + +func existingStoreInteractivePlan(canPrompt, hasOverrides, hasEncryption bool) (promptURI bool, askKeepEncryption bool) { + if !canPrompt || hasOverrides { + return false, false } + return true, hasEncryption } // globalFlagsFromProfileStore builds a globalFlags populated from a ProfileStore, @@ -526,6 +816,16 @@ func envRef(name string) string { return "env://" + name } +func firstNonEmpty(values ...string) string { + for _, v := range values { + v = strings.TrimSpace(v) + if v != "" { + return v + } + } + return "" +} + func storeHasExplicitEncryption(s cloudstic.ProfileStore) bool { return s.PasswordEnv != "" || s.EncryptionKeyEnv != "" || s.RecoveryKeyEnv != "" || s.PasswordSecret != "" || diff --git a/cmd/cloudstic/cmd_store_test.go b/cmd/cloudstic/cmd_store_test.go index 5da2656..e40bb82 100644 --- a/cmd/cloudstic/cmd_store_test.go +++ b/cmd/cloudstic/cmd_store_test.go @@ -1,12 +1,16 @@ package main import ( + "context" + "errors" "os" "path/filepath" + "runtime" "strings" "testing" cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/pkg/keychain" ) func TestRunStoreNewAndListAndShow(t *testing.T) { @@ -82,6 +86,57 @@ func TestRunStoreNew_RequiresNameAndURI(t *testing.T) { } } +func TestRunStoreNew_ExistingStorePrefillsUnsetValues(t *testing.T) { + tmpDir := t.TempDir() + profilesPath := filepath.Join(tmpDir, "profiles.yaml") + + cfg := &cloudstic.ProfilesConfig{ + Version: 1, + Stores: map[string]cloudstic.ProfileStore{ + "prod": { + URI: "s3:bucket/backups", + S3Region: "us-east-1", + S3Profile: "old-profile", + PasswordSecret: "env://OLD_PASSWORD", + }, + }, + } + if err := cloudstic.SaveProfilesFile(profilesPath, cfg); err != nil { + t.Fatalf("SaveProfilesFile: %v", err) + } + + os.Args = []string{ + "cloudstic", "store", "new", + "-profiles-file", profilesPath, + "-name", "prod", + "-s3-profile", "new-profile", + } + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut} + if code := r.runStore(); code != 0 { + t.Fatalf("store new failed: %s", errOut.String()) + } + + updated, err := cloudstic.LoadProfilesFile(profilesPath) + if err != nil { + t.Fatalf("LoadProfilesFile: %v", err) + } + s := updated.Stores["prod"] + if s.URI != "s3:bucket/backups" { + t.Fatalf("uri=%q", s.URI) + } + if s.S3Region != "us-east-1" { + t.Fatalf("s3 region=%q", s.S3Region) + } + if s.S3Profile != "new-profile" { + t.Fatalf("s3 profile=%q", s.S3Profile) + } + if s.PasswordSecret != "env://OLD_PASSWORD" { + t.Fatalf("password secret=%q", s.PasswordSecret) + } +} + func TestNoPromptDisablesInteractivity(t *testing.T) { var out strings.Builder var errOut strings.Builder @@ -250,13 +305,91 @@ func TestCheckOrInitStore_AlreadyInitialized(t *testing.T) { var out strings.Builder var errOut strings.Builder r := &runner{out: &out, errOut: &errOut} - r.checkOrInitStore(cfg, "test", profilesPath) + if err := r.checkOrInitStore(cfg, "test", profilesPath, false, true, true); err != nil { + t.Fatalf("checkOrInitStore: %v", err) + } if !strings.Contains(out.String(), "already initialized") { t.Fatalf("expected 'already initialized' in output, got:\n%s", out.String()) } } +func TestCheckOrInitStore_InitializedEncrypted_ValidCredentials(t *testing.T) { + tmpDir := t.TempDir() + storePath := filepath.Join(tmpDir, "store") + + s := cloudstic.ProfileStore{URI: "local:" + storePath} + g, err := globalFlagsFromProfileStore(s) + if err != nil { + t.Fatalf("globalFlagsFromProfileStore: %v", err) + } + raw, err := g.initObjectStore() + if err != nil { + t.Fatalf("initObjectStore: %v", err) + } + _, err = cloudstic.InitRepo(t.Context(), raw, cloudstic.WithInitCredentials(keychain.Chain{keychain.WithPassword("correct-password")})) + if err != nil { + t.Fatalf("InitRepo: %v", err) + } + + t.Setenv("VERIFY_STORE_PASSWORD", "correct-password") + cfg := &cloudstic.ProfilesConfig{Version: 1, Stores: map[string]cloudstic.ProfileStore{ + "test": { + URI: s.URI, + PasswordSecret: "env://VERIFY_STORE_PASSWORD", + }, + }} + + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut} + if err := r.checkOrInitStore(cfg, "test", "profiles.yaml", false, true, true); err != nil { + t.Fatalf("checkOrInitStore: %v", err) + } + if !strings.Contains(out.String(), "Repository is encrypted; verifying configured credentials") { + t.Fatalf("missing verification message in output: %s", out.String()) + } + if !strings.Contains(out.String(), "Encryption credentials are valid") { + t.Fatalf("missing success message in output: %s", out.String()) + } +} + +func TestCheckOrInitStore_InitializedEncrypted_InvalidCredentials(t *testing.T) { + tmpDir := t.TempDir() + storePath := filepath.Join(tmpDir, "store") + + s := cloudstic.ProfileStore{URI: "local:" + storePath} + g, err := globalFlagsFromProfileStore(s) + if err != nil { + t.Fatalf("globalFlagsFromProfileStore: %v", err) + } + raw, err := g.initObjectStore() + if err != nil { + t.Fatalf("initObjectStore: %v", err) + } + _, err = cloudstic.InitRepo(t.Context(), raw, cloudstic.WithInitCredentials(keychain.Chain{keychain.WithPassword("correct-password")})) + if err != nil { + t.Fatalf("InitRepo: %v", err) + } + + t.Setenv("VERIFY_STORE_PASSWORD_BAD", "wrong-password") + cfg := &cloudstic.ProfilesConfig{Version: 1, Stores: map[string]cloudstic.ProfileStore{ + "test": { + URI: s.URI, + PasswordSecret: "env://VERIFY_STORE_PASSWORD_BAD", + }, + }} + + r := &runner{out: &strings.Builder{}, errOut: &strings.Builder{}} + err = r.checkOrInitStore(cfg, "test", "profiles.yaml", false, true, true) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "configured encryption credentials are invalid") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestGlobalFlagsFromProfileStore_ResolvesEnvVars(t *testing.T) { t.Setenv("TEST_AK", "my-access-key") t.Setenv("TEST_SK", "my-secret-key") @@ -559,6 +692,506 @@ func TestRunStoreNew_WithAllS3Options(t *testing.T) { } } +func TestRunStoreNew_WithSecretRefFlags(t *testing.T) { + tmpDir := t.TempDir() + profilesPath := filepath.Join(tmpDir, "profiles.yaml") + + os.Args = []string{ + "cloudstic", "store", "new", + "-profiles-file", profilesPath, + "-name", "secrets-store", + "-uri", "s3:bucket", + "-s3-access-key-secret", "env://AWS_ACCESS_KEY_ID", + "-s3-secret-key-secret", "keychain://cloudstic/prod/s3-secret", + "-password-secret", "keychain://cloudstic/prod/password", + "-encryption-key-secret", "wincred://cloudstic/prod/encryption-key", + "-recovery-key-secret", "secret-service://cloudstic/prod/recovery-key", + "-store-sftp-password-secret", "env://STORE_SFTP_PASSWORD", + "-store-sftp-key-secret", "env://STORE_SFTP_KEY", + } + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut} + if code := r.runStore(); code != 0 { + t.Fatalf("store new failed: %s", errOut.String()) + } + + raw, err := os.ReadFile(profilesPath) + if err != nil { + t.Fatalf("read profiles: %v", err) + } + yaml := string(raw) + for _, want := range []string{ + "s3_access_key_secret: env://AWS_ACCESS_KEY_ID", + "s3_secret_key_secret: keychain://cloudstic/prod/s3-secret", + "password_secret: keychain://cloudstic/prod/password", + "encryption_key_secret: wincred://cloudstic/prod/encryption-key", + "recovery_key_secret: secret-service://cloudstic/prod/recovery-key", + "store_sftp_password_secret: env://STORE_SFTP_PASSWORD", + "store_sftp_key_secret: env://STORE_SFTP_KEY", + } { + if !strings.Contains(yaml, want) { + t.Fatalf("expected %q in YAML:\n%s", want, yaml) + } + } +} + +func TestPromptSecretReferenceWithFns_DarwinKeychain(t *testing.T) { + gotRef, err := promptSecretReferenceWithFns( + "darwin", + "prod-store", + "repository password", + "CLOUDSTIC_PASSWORD", + "password", + func(_ string, _ []string) (string, error) { return "macOS Keychain (keychain://)", nil }, + func(label, def string) (string, error) { return def, nil }, + func(_ string) (string, error) { return "super-secret", nil }, + func(string) (string, bool) { return "", false }, + func(context.Context, string, string) (bool, error) { return false, nil }, + func(_ context.Context, service, account, value string) error { + if service != "cloudstic/store/prod-store" { + t.Fatalf("service=%q", service) + } + if account != "password" { + t.Fatalf("account=%q", account) + } + if value != "super-secret" { + t.Fatalf("value=%q", value) + } + return nil + }, + ) + if err != nil { + t.Fatalf("promptSecretReferenceWithFns: %v", err) + } + if gotRef != "keychain://cloudstic/store/prod-store/password" { + t.Fatalf("ref=%q", gotRef) + } +} + +func TestPromptSecretReferenceWithFns_EnvFallback(t *testing.T) { + gotRef, err := promptSecretReferenceWithFns( + "darwin", + "prod-store", + "repository password", + "CLOUDSTIC_PASSWORD", + "password", + func(_ string, _ []string) (string, error) { return "Environment variable (env://)", nil }, + func(label, def string) (string, error) { + if label != "Env var name" { + t.Fatalf("unexpected label: %s", label) + } + return def, nil + }, + func(_ string) (string, error) { + t.Fatal("promptSecret should not be called") + return "", nil + }, + func(string) (string, bool) { return "", true }, + func(context.Context, string, string) (bool, error) { + t.Fatal("nativeSecretExists should not be called") + return false, nil + }, + func(context.Context, string, string, string) error { + t.Fatal("writeNativeSecret should not be called") + return nil + }, + ) + if err != nil { + t.Fatalf("promptSecretReferenceWithFns: %v", err) + } + if gotRef != "env://CLOUDSTIC_PASSWORD" { + t.Fatalf("ref=%q", gotRef) + } +} + +func TestPromptSecretReferenceWithFns_KeychainWriteError(t *testing.T) { + _, err := promptSecretReferenceWithFns( + "darwin", + "prod-store", + "repository password", + "CLOUDSTIC_PASSWORD", + "password", + func(_ string, _ []string) (string, error) { return "macOS Keychain (keychain://)", nil }, + func(_ string, def string) (string, error) { return def, nil }, + func(_ string) (string, error) { return "secret", nil }, + func(string) (string, bool) { return "", false }, + func(context.Context, string, string) (bool, error) { return false, nil }, + func(context.Context, string, string, string) error { return errors.New("write failed") }, + ) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "write failed") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPromptSecretReferenceWithFns_EmptySecret(t *testing.T) { + _, err := promptSecretReferenceWithFns( + "darwin", + "prod-store", + "repository password", + "CLOUDSTIC_PASSWORD", + "password", + func(_ string, _ []string) (string, error) { return "macOS Keychain (keychain://)", nil }, + func(_ string, def string) (string, error) { return def, nil }, + func(_ string) (string, error) { return "", nil }, + func(string) (string, bool) { return "", false }, + func(context.Context, string, string) (bool, error) { return false, nil }, + func(context.Context, string, string, string) error { return nil }, + ) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "cannot be empty") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPromptSecretReferenceWithFns_DarwinKeychainAdoptsExisting(t *testing.T) { + gotRef, err := promptSecretReferenceWithFns( + "darwin", + "prod-store", + "repository password", + "CLOUDSTIC_PASSWORD", + "password", + func(_ string, _ []string) (string, error) { return "macOS Keychain (keychain://)", nil }, + func(_ string, def string) (string, error) { return def, nil }, + func(_ string) (string, error) { + t.Fatal("promptSecret should not be called when key exists") + return "", nil + }, + func(string) (string, bool) { return "", false }, + func(_ context.Context, service, account string) (bool, error) { + if service != "cloudstic/store/prod-store" { + t.Fatalf("service=%q", service) + } + if account != "password" { + t.Fatalf("account=%q", account) + } + return true, nil + }, + func(context.Context, string, string, string) error { + t.Fatal("writeNativeSecret should not be called when key exists") + return nil + }, + ) + if err != nil { + t.Fatalf("promptSecretReferenceWithFns: %v", err) + } + if gotRef != "keychain://cloudstic/store/prod-store/password" { + t.Fatalf("ref=%q", gotRef) + } +} + +func TestPromptSecretReferenceWithFns_DarwinEnvUnsetSwitchesToKeychain(t *testing.T) { + selectCall := 0 + gotRef, err := promptSecretReferenceWithFns( + "darwin", + "prod-store", + "repository password", + "CLOUDSTIC_PASSWORD", + "password", + func(_ string, _ []string) (string, error) { + selectCall++ + if selectCall == 1 { + return "Environment variable (env://)", nil + } + return "Store in macOS Keychain instead (keychain://)", nil + }, + func(label, def string) (string, error) { + if label != "Env var name" { + t.Fatalf("unexpected prompt line label: %s", label) + } + return "UNSET_PASSWORD", nil + }, + func(_ string) (string, error) { return "secret-value", nil }, + func(string) (string, bool) { return "", false }, + func(context.Context, string, string) (bool, error) { return false, nil }, + func(context.Context, string, string, string) error { return nil }, + ) + if err != nil { + t.Fatalf("promptSecretReferenceWithFns: %v", err) + } + if gotRef != "keychain://cloudstic/store/prod-store/password" { + t.Fatalf("ref=%q", gotRef) + } +} + +func TestCheckOrInitStore_MissingSecretAllowed(t *testing.T) { + cfg := &cloudstic.ProfilesConfig{Version: 1, Stores: map[string]cloudstic.ProfileStore{ + "test": { + URI: "local:/tmp/store", + PasswordSecret: "env://MISSING_STORE_PASSWORD", + }, + }} + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut} + + if err := r.checkOrInitStore(cfg, "test", "profiles.yaml", true, true, true); err != nil { + t.Fatalf("checkOrInitStore: %v", err) + } + if !strings.Contains(errOut.String(), "cloudstic store verify test") { + t.Fatalf("expected follow-up hint in stderr, got: %s", errOut.String()) + } +} + +func TestCheckOrInitStore_MissingSecretAllowedSilent(t *testing.T) { + cfg := &cloudstic.ProfilesConfig{Version: 1, Stores: map[string]cloudstic.ProfileStore{ + "test": { + URI: "local:/tmp/store", + PasswordSecret: "env://MISSING_STORE_PASSWORD", + }, + }} + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut} + + if err := r.checkOrInitStore(cfg, "test", "profiles.yaml", true, false, true); err != nil { + t.Fatalf("checkOrInitStore: %v", err) + } + if errOut.String() != "" { + t.Fatalf("expected silent skip for missing secrets, got: %s", errOut.String()) + } +} + +func TestRunStoreVerify_MissingSecretFails(t *testing.T) { + tmpDir := t.TempDir() + profilesPath := filepath.Join(tmpDir, "profiles.yaml") + cfg := &cloudstic.ProfilesConfig{Version: 1, Stores: map[string]cloudstic.ProfileStore{ + "test": { + URI: "local:/tmp/store", + PasswordSecret: "env://MISSING_VERIFY_PASSWORD", + }, + }} + if err := cloudstic.SaveProfilesFile(profilesPath, cfg); err != nil { + t.Fatalf("SaveProfilesFile: %v", err) + } + + os.Args = []string{"cloudstic", "store", "verify", "-profiles-file", profilesPath, "test"} + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut} + if code := r.runStore(); code == 0 { + t.Fatal("expected non-zero exit code") + } + if !strings.Contains(errOut.String(), "could not resolve store credentials") { + t.Fatalf("unexpected stderr: %s", errOut.String()) + } +} + +func TestStoreHasExplicitEncryption(t *testing.T) { + if storeHasExplicitEncryption(cloudstic.ProfileStore{}) { + t.Fatal("expected false for empty store") + } + if !storeHasExplicitEncryption(cloudstic.ProfileStore{PasswordSecret: "env://CLOUDSTIC_PASSWORD"}) { + t.Fatal("expected true when password secret is set") + } +} + +func TestHasStoreNewOverrideFlags(t *testing.T) { + if hasStoreNewOverrideFlags(map[string]bool{"name": true}) { + t.Fatal("name-only should not count as override") + } + if hasStoreNewOverrideFlags(map[string]bool{"profiles-file": true}) { + t.Fatal("profiles-file-only should not count as override") + } + if hasStoreNewOverrideFlags(map[string]bool{"name": true, "profiles-file": true}) { + t.Fatal("identity-only flags should not count as override") + } + if !hasStoreNewOverrideFlags(map[string]bool{"name": true, "uri": true}) { + t.Fatal("uri should count as override") + } +} + +func TestExistingStoreInteractivePlan(t *testing.T) { + tests := []struct { + name string + canPrompt bool + hasOverrides bool + hasEncryption bool + wantURI bool + wantAsk bool + }{ + {name: "no prompt", canPrompt: false, hasOverrides: false, hasEncryption: true, wantURI: false, wantAsk: false}, + {name: "has overrides", canPrompt: true, hasOverrides: true, hasEncryption: true, wantURI: false, wantAsk: false}, + {name: "interactive no encryption", canPrompt: true, hasOverrides: false, hasEncryption: false, wantURI: true, wantAsk: false}, + {name: "interactive with encryption", canPrompt: true, hasOverrides: false, hasEncryption: true, wantURI: true, wantAsk: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotURI, gotAsk := existingStoreInteractivePlan(tc.canPrompt, tc.hasOverrides, tc.hasEncryption) + if gotURI != tc.wantURI || gotAsk != tc.wantAsk { + t.Fatalf("got (uri=%v, ask=%v), want (uri=%v, ask=%v)", gotURI, gotAsk, tc.wantURI, tc.wantAsk) + } + }) + } +} + +func TestConfigureStoreEncryptionSelection_Password(t *testing.T) { + var out strings.Builder + s, err := configureStoreEncryptionSelection( + cloudstic.ProfileStore{}, + "prod", + "Password (recommended for interactive use)", + func(string, string, string, string) (string, error) { return "env://MY_BACKUP_PASSWORD", nil }, + func(string, string) (string, error) { return "", nil }, + &out, + ) + if err != nil { + t.Fatalf("configureStoreEncryptionSelection: %v", err) + } + if s.PasswordSecret != "env://MY_BACKUP_PASSWORD" { + t.Fatalf("password secret=%q", s.PasswordSecret) + } + if !strings.Contains(out.String(), "Encryption: password via env://MY_BACKUP_PASSWORD") { + t.Fatalf("unexpected output: %s", out.String()) + } +} + +func TestConfigureStoreEncryptionSelection_KMS(t *testing.T) { + var out strings.Builder + s, err := configureStoreEncryptionSelection( + cloudstic.ProfileStore{}, + "prod", + "AWS KMS key (enterprise)", + func(string, string, string, string) (string, error) { return "", nil }, + func(label, def string) (string, error) { + switch label { + case "KMS key ARN": + return "arn:aws:kms:us-east-1:123:key/abc", nil + case "KMS region": + return "us-east-1", nil + default: + return def, nil + } + }, + &out, + ) + if err != nil { + t.Fatalf("configureStoreEncryptionSelection: %v", err) + } + if s.KMSKeyARN == "" || s.KMSRegion != "us-east-1" { + t.Fatalf("unexpected kms values: arn=%q region=%q", s.KMSKeyARN, s.KMSRegion) + } +} + +func TestConfigureStoreEncryptionSelection_NoEncryption(t *testing.T) { + var out strings.Builder + _, err := configureStoreEncryptionSelection( + cloudstic.ProfileStore{}, + "prod", + "No encryption (not recommended)", + func(string, string, string, string) (string, error) { return "", nil }, + func(string, string) (string, error) { return "", nil }, + &out, + ) + if err != nil { + t.Fatalf("configureStoreEncryptionSelection: %v", err) + } + if !strings.Contains(out.String(), "Encryption: none") { + t.Fatalf("unexpected output: %s", out.String()) + } +} + +func TestConfigureStoreEncryptionSelection_KMSError(t *testing.T) { + _, err := configureStoreEncryptionSelection( + cloudstic.ProfileStore{}, + "prod", + "AWS KMS key (enterprise)", + func(string, string, string, string) (string, error) { return "", nil }, + func(label, def string) (string, error) { + if label == "KMS key ARN" { + return "", nil + } + return def, nil + }, + &strings.Builder{}, + ) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "KMS key ARN is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPromptSecretReference_EnvInteractive(t *testing.T) { + t.Setenv("MY_ENV", "set-for-test") + if runtime.GOOS == "darwin" { + setInteractiveStdinLines(t, "1", "MY_ENV") + } else { + setInteractiveStdinLines(t, "MY_ENV") + } + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut} + + got, err := r.promptSecretReference("prod", "repository password", "CLOUDSTIC_PASSWORD", "password") + if err != nil { + t.Fatalf("promptSecretReference: %v", err) + } + if got != "env://MY_ENV" { + t.Fatalf("ref=%q want env://MY_ENV", got) + } +} + +func TestPromptEncryptionConfig_PasswordViaEnvRef(t *testing.T) { + t.Setenv("MY_BACKUP_PASSWORD", "set-for-test") + tmp := t.TempDir() + profilesPath := filepath.Join(tmp, "profiles.yaml") + cfg := &cloudstic.ProfilesConfig{ + Version: 1, + Stores: map[string]cloudstic.ProfileStore{ + "prod": {URI: "local:/tmp/store"}, + }, + } + + if runtime.GOOS == "darwin" { + setInteractiveStdinLines(t, "1", "1", "MY_BACKUP_PASSWORD") + } else { + setInteractiveStdinLines(t, "1", "MY_BACKUP_PASSWORD") + } + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut} + + r.promptEncryptionConfig(cfg, "prod", profilesPath) + + s := cfg.Stores["prod"] + if s.PasswordSecret != "env://MY_BACKUP_PASSWORD" { + t.Fatalf("password secret=%q", s.PasswordSecret) + } + raw, err := os.ReadFile(profilesPath) + if err != nil { + t.Fatalf("read profiles file: %v", err) + } + if !strings.Contains(string(raw), "password_secret: env://MY_BACKUP_PASSWORD") { + t.Fatalf("expected saved password_secret in YAML:\n%s", string(raw)) + } +} + +func setInteractiveStdinLines(t *testing.T, lines ...string) { + t.Helper() + orig := os.Stdin + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdin = r + for _, line := range lines { + _, _ = w.WriteString(line + "\n") + } + _ = w.Close() + t.Cleanup(func() { + os.Stdin = orig + _ = r.Close() + }) +} + func TestValidRefName(t *testing.T) { valid := []string{"abc", "a-b", "a.b", "a_b", "A1", "test-store.v2"} for _, name := range valid { diff --git a/cmd/cloudstic/completion.go b/cmd/cloudstic/completion.go index 03bb8b0..7fd0244 100644 --- a/cmd/cloudstic/completion.go +++ b/cmd/cloudstic/completion.go @@ -170,17 +170,19 @@ _cloudstic() { *) store_sub="${words[j]}"; break ;; esac done - if [[ -z "$store_sub" ]]; then - COMPREPLY=($(compgen -W "list show new" -- "$cur")) - return - fi - case "$store_sub" in - list) - cmd_flags="-profiles-file" ;; - show) - cmd_flags="-profiles-file" ;; - new) - cmd_flags="-profiles-file -name -uri -s3-region -s3-profile -s3-endpoint -s3-access-key -s3-secret-key -s3-access-key-env -s3-secret-key-env -s3-profile-env -store-sftp-password -store-sftp-key -store-sftp-password-env -store-sftp-key-env -password-env -encryption-key-env -recovery-key-env -kms-key-arn -kms-region -kms-endpoint" ;; + if [[ -z "$store_sub" ]]; then + COMPREPLY=($(compgen -W "list show new verify" -- "$cur")) + return + fi + case "$store_sub" in + list) + cmd_flags="-profiles-file" ;; + show) + cmd_flags="-profiles-file" ;; + verify) + cmd_flags="-profiles-file" ;; + new) + cmd_flags="-profiles-file -name -uri -s3-region -s3-profile -s3-endpoint -s3-access-key -s3-secret-key -s3-access-key-secret -s3-secret-key-secret -s3-access-key-env -s3-secret-key-env -s3-profile-env -store-sftp-password -store-sftp-key -store-sftp-password-secret -store-sftp-key-secret -store-sftp-password-env -store-sftp-key-env -password-secret -encryption-key-secret -recovery-key-secret -password-env -encryption-key-env -recovery-key-env -kms-key-arn -kms-region -kms-endpoint" ;; *) cmd_flags="" ;; esac @@ -419,11 +421,12 @@ _cloudstic() { ;; store) local -a store_commands - store_commands=( - 'list:List configured stores' - 'show:Show one store and its configuration' - 'new:Create or update a store entry' - ) + store_commands=( + 'list:List configured stores' + 'show:Show one store and its configuration' + 'new:Create or update a store entry' + 'verify:Verify store credentials and connectivity' + ) local store_sub local -i si=$((i+1)) while (( si < CURRENT )); do @@ -437,14 +440,17 @@ _cloudstic() { _describe -t store-commands 'store subcommand' store_commands return fi - case "$store_sub" in - list) - _arguments '-profiles-file[Path to profiles YAML file]:path:_files' - ;; - show) - _arguments '-profiles-file[Path to profiles YAML file]:path:_files' ':store name:' - ;; - new) + case "$store_sub" in + list) + _arguments '-profiles-file[Path to profiles YAML file]:path:_files' + ;; + show) + _arguments '-profiles-file[Path to profiles YAML file]:path:_files' ':store name:' + ;; + verify) + _arguments '-profiles-file[Path to profiles YAML file]:path:_files' ':store name:' + ;; + new) _arguments \ '-profiles-file[Path to profiles YAML file]:path:_files' \ '-name[Store reference name]:name:' \ @@ -454,13 +460,20 @@ _cloudstic() { '-s3-endpoint[S3-compatible endpoint URL]:url:' \ '-s3-access-key[S3 static access key]:key:' \ '-s3-secret-key[S3 static secret key]:key:' \ + '-s3-access-key-secret[Secret ref for S3 access key]:ref:' \ + '-s3-secret-key-secret[Secret ref for S3 secret key]:ref:' \ '-s3-access-key-env[Env var for S3 access key]:var:' \ '-s3-secret-key-env[Env var for S3 secret key]:var:' \ '-s3-profile-env[Env var for AWS profile]:var:' \ '-store-sftp-password[SFTP password]:password:' \ '-store-sftp-key[SFTP private key path]:path:_files' \ + '-store-sftp-password-secret[Secret ref for SFTP password]:ref:' \ + '-store-sftp-key-secret[Secret ref for SFTP key path]:ref:' \ '-store-sftp-password-env[Env var for SFTP password]:var:' \ '-store-sftp-key-env[Env var for SFTP key path]:var:' \ + '-password-secret[Secret ref for repository password]:ref:' \ + '-encryption-key-secret[Secret ref for platform key]:ref:' \ + '-recovery-key-secret[Secret ref for recovery key mnemonic]:ref:' \ '-password-env[Env var for repository password]:var:' \ '-encryption-key-env[Env var for platform key]:var:' \ '-recovery-key-env[Env var for recovery key mnemonic]:var:' \ diff --git a/cmd/cloudstic/interactive.go b/cmd/cloudstic/interactive.go index 0678a78..527bbe6 100644 --- a/cmd/cloudstic/interactive.go +++ b/cmd/cloudstic/interactive.go @@ -1,17 +1,21 @@ package main import ( - "bufio" "fmt" "os" "strconv" "strings" "github.com/moby/term" + xterm "golang.org/x/term" ) func (r *runner) canPrompt() bool { - return !r.noPrompt && term.IsTerminal(os.Stdin.Fd()) && term.IsTerminal(os.Stdout.Fd()) + stdin := r.stdin + if stdin == nil { + stdin = os.Stdin + } + return !r.noPrompt && term.IsTerminal(stdin.Fd()) && term.IsTerminal(os.Stdout.Fd()) } func (r *runner) promptLine(label, defaultValue string) (string, error) { @@ -20,8 +24,7 @@ func (r *runner) promptLine(label, defaultValue string) (string, error) { } else { _, _ = fmt.Fprintf(r.errOut, "%s: ", label) } - reader := bufio.NewReader(os.Stdin) - line, err := reader.ReadString('\n') + line, err := r.lineReader().ReadString('\n') if err != nil { return "", err } @@ -68,3 +71,17 @@ func (r *runner) promptSelect(label string, options []string) (string, error) { return options[n-1], nil } } + +func (r *runner) promptSecret(label string) (string, error) { + _, _ = fmt.Fprintf(r.errOut, "%s: ", label) + stdin := r.stdin + if stdin == nil { + stdin = os.Stdin + } + b, err := xterm.ReadPassword(int(stdin.Fd())) + _, _ = fmt.Fprintln(r.errOut) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil +} diff --git a/cmd/cloudstic/runner.go b/cmd/cloudstic/runner.go index f005746..d014716 100644 --- a/cmd/cloudstic/runner.go +++ b/cmd/cloudstic/runner.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "fmt" "io" "os" @@ -11,6 +12,8 @@ type runner struct { errOut io.Writer client cloudsticClient noPrompt bool + stdin *os.File + lineIn *bufio.Reader } func newRunner() *runner { @@ -18,9 +21,20 @@ func newRunner() *runner { out: os.Stdout, errOut: os.Stderr, noPrompt: hasGlobalFlag("no-prompt"), + stdin: os.Stdin, } } +func (r *runner) lineReader() *bufio.Reader { + if r.stdin == nil { + r.stdin = os.Stdin + } + if r.lineIn == nil { + r.lineIn = bufio.NewReader(r.stdin) + } + return r.lineIn +} + // hasGlobalFlag checks whether a boolean flag appears anywhere in os.Args. // This is used for flags that must be parsed before subcommand flag sets. func hasGlobalFlag(name string) bool { diff --git a/cmd/cloudstic/secret_store_darwin.go b/cmd/cloudstic/secret_store_darwin.go new file mode 100644 index 0000000..6125152 --- /dev/null +++ b/cmd/cloudstic/secret_store_darwin.go @@ -0,0 +1,35 @@ +//go:build darwin + +package main + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +var execCommandContext = exec.CommandContext + +func saveSecretToNativeStore(ctx context.Context, service, account, value string) error { + cmd := execCommandContext(ctx, "security", "add-generic-password", "-U", "-s", service, "-a", account, "-w", value) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return fmt.Errorf("save secret in macOS keychain failed: %s", msg) + } + return nil +} + +func nativeSecretExists(ctx context.Context, service, account string) (bool, error) { + cmd := execCommandContext(ctx, "security", "find-generic-password", "-s", service, "-a", account, "-w") + if err := cmd.Run(); err != nil { + return false, nil + } + return true, nil +} diff --git a/cmd/cloudstic/secret_store_darwin_test.go b/cmd/cloudstic/secret_store_darwin_test.go new file mode 100644 index 0000000..5449117 --- /dev/null +++ b/cmd/cloudstic/secret_store_darwin_test.go @@ -0,0 +1,101 @@ +//go:build darwin + +package main + +import ( + "context" + "os/exec" + "reflect" + "strings" + "testing" +) + +func TestSaveSecretToNativeStore_Success(t *testing.T) { + orig := execCommandContext + defer func() { execCommandContext = orig }() + + var gotName string + var gotArgs []string + execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = append([]string{}, args...) + return exec.CommandContext(ctx, "sh", "-c", "exit 0") + } + + err := saveSecretToNativeStore(context.Background(), "cloudstic/store/prod", "password", "super-secret") + if err != nil { + t.Fatalf("saveSecretToNativeStore: %v", err) + } + if gotName != "security" { + t.Fatalf("command name=%q want security", gotName) + } + wantArgs := []string{"add-generic-password", "-U", "-s", "cloudstic/store/prod", "-a", "password", "-w", "super-secret"} + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("command args=%v want=%v", gotArgs, wantArgs) + } +} + +func TestSaveSecretToNativeStore_Failure(t *testing.T) { + orig := execCommandContext + defer func() { execCommandContext = orig }() + + execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "echo keychain failed 1>&2; exit 1") + } + + err := saveSecretToNativeStore(context.Background(), "svc", "acct", "secret") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "save secret in macOS keychain failed") { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(err.Error(), "keychain failed") { + t.Fatalf("expected stderr in error: %v", err) + } +} + +func TestNativeSecretExists_Success(t *testing.T) { + orig := execCommandContext + defer func() { execCommandContext = orig }() + + var gotName string + var gotArgs []string + execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = append([]string{}, args...) + return exec.CommandContext(ctx, "sh", "-c", "exit 0") + } + + exists, err := nativeSecretExists(context.Background(), "cloudstic/store/prod", "password") + if err != nil { + t.Fatalf("nativeSecretExists: %v", err) + } + if !exists { + t.Fatal("expected exists=true") + } + if gotName != "security" { + t.Fatalf("command name=%q want security", gotName) + } + wantArgs := []string{"find-generic-password", "-s", "cloudstic/store/prod", "-a", "password", "-w"} + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("command args=%v want=%v", gotArgs, wantArgs) + } +} + +func TestNativeSecretExists_NotFound(t *testing.T) { + orig := execCommandContext + defer func() { execCommandContext = orig }() + + execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "exit 1") + } + + exists, err := nativeSecretExists(context.Background(), "svc", "acct") + if err != nil { + t.Fatalf("nativeSecretExists: %v", err) + } + if exists { + t.Fatal("expected exists=false") + } +} diff --git a/cmd/cloudstic/secret_store_stub.go b/cmd/cloudstic/secret_store_stub.go new file mode 100644 index 0000000..577edd9 --- /dev/null +++ b/cmd/cloudstic/secret_store_stub.go @@ -0,0 +1,16 @@ +//go:build !darwin + +package main + +import ( + "context" + "errors" +) + +func saveSecretToNativeStore(_ context.Context, _, _, _ string) error { + return errors.New("native secret write not supported on this platform") +} + +func nativeSecretExists(_ context.Context, _, _ string) (bool, error) { + return false, nil +} diff --git a/cmd/cloudstic/usage.go b/cmd/cloudstic/usage.go index 343794c..60a7e00 100644 --- a/cmd/cloudstic/usage.go +++ b/cmd/cloudstic/usage.go @@ -26,6 +26,7 @@ func printUsage() { {"store new", "Create or update a store entry in profiles.yaml"}, {"store list", "List configured stores"}, {"store show", "Show one store and its configuration"}, + {"store verify", "Verify one store's credentials and connectivity"}, {"profile new", "Create or update a backup profile in profiles.yaml"}, {"profile list", "List stores, auth entries, and backup profiles"}, {"profile show", "Show one profile and resolved store/auth references"}, @@ -158,6 +159,11 @@ func printUsage() { t.Note(" Show one store and its configuration.") t.Blank() + t.Command("store verify", "") + t.Flags([][2]string{{"-profiles-file ", ui.Env("Path to profiles YAML file", "CLOUDSTIC_PROFILES_FILE")}}) + t.Note(" Resolve store credentials and verify connectivity.") + t.Blank() + t.Command("store new", "") t.Flags([][2]string{ {"-name ", "Store reference name"}, @@ -167,13 +173,20 @@ func printUsage() { {"-s3-endpoint ", "S3-compatible endpoint URL"}, {"-s3-access-key ", "S3 static access key"}, {"-s3-secret-key ", "S3 static secret key"}, + {"-s3-access-key-secret ", "Secret reference for S3 access key (env://, keychain://, wincred://, secret-service://)"}, + {"-s3-secret-key-secret ", "Secret reference for S3 secret key (env://, keychain://, wincred://, secret-service://)"}, {"-s3-access-key-env ", "Env var name for S3 access key"}, {"-s3-secret-key-env ", "Env var name for S3 secret key"}, {"-s3-profile-env ", "Env var name for AWS profile"}, {"-store-sftp-password ", "SFTP password"}, {"-store-sftp-key ", "Path to SFTP private key"}, + {"-store-sftp-password-secret ", "Secret reference for SFTP password (env://, keychain://, wincred://, secret-service://)"}, + {"-store-sftp-key-secret ", "Secret reference for SFTP key path (env://, keychain://, wincred://, secret-service://)"}, {"-store-sftp-password-env ", "Env var name for SFTP password"}, {"-store-sftp-key-env ", "Env var name for SFTP key path"}, + {"-password-secret ", "Secret reference for repository password (env://, keychain://, wincred://, secret-service://)"}, + {"-encryption-key-secret ", "Secret reference for platform key (env://, keychain://, wincred://, secret-service://)"}, + {"-recovery-key-secret ", "Secret reference for recovery key mnemonic (env://, keychain://, wincred://, secret-service://)"}, {"-password-env ", "Env var name for repository password"}, {"-encryption-key-env ", "Env var name for platform key (hex)"}, {"-recovery-key-env ", "Env var name for recovery key mnemonic"}, @@ -183,7 +196,8 @@ func printUsage() { {"-profiles-file ", ui.Env("Path to profiles YAML file", "CLOUDSTIC_PROFILES_FILE")}, }) t.Note(" Create or update a store entry in profiles.yaml.", - " Encryption credentials use env var indirection: -password-env, -encryption-key-env.", + " Prefer secret refs: -password-secret / -encryption-key-secret / -recovery-key-secret.", + " Legacy -*-env flags are still supported and auto-converted to env:// refs on write.", " KMS settings are stored directly (ARN is not a secret).", ) t.Blank() diff --git a/docs/encryption.md b/docs/encryption.md index 995fc22..7f076b1 100644 --- a/docs/encryption.md +++ b/docs/encryption.md @@ -258,6 +258,37 @@ by both the CLI tool and the web application. Only key management differs: | Key management | Platform-managed, stored in DB + B2 | User-managed password or platform key| | Key derivation | Platform key wraps master key | Argon2id(password) wraps master key | | Key storage | `encryption_key_slots` table + B2 | `keys/-