diff --git a/internal/cli/config.go b/internal/cli/config.go index 80f3e91..9f3f436 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -1,13 +1,12 @@ package cli import ( - "errors" + "context" "fmt" "os" "github.com/spf13/cobra" - "github.com/sufforest/drift/internal/credentials" "github.com/sufforest/drift/internal/workspace" ) @@ -26,53 +25,48 @@ func configSetParentCmd() *cobra.Command { Short: "Deprecated alias for `drift parent set` (kept for script compatibility)", Hidden: true, Long: `Deprecated: prefer 'drift parent set'. This command remains for -scripts that pre-date the 'drift parent' namespace but does NOT run the -live HEAD verification step. New code should call 'drift parent set'.`, +scripts that pre-date the 'drift parent' namespace and now reuses the +same verified credential-replacement flow as 'drift parent set'.`, RunE: runConfigSetParent, } cmd.Flags().String("access-key", "", "New access key ID (defaults to $DRIFT_ACCESS_KEY_ID)") - cmd.Flags().String("secret-key", "", "New secret access key (defaults to $DRIFT_SECRET_ACCESS_KEY)") return cmd } func runConfigSetParent(cmd *cobra.Command, _ []string) error { - dir, err := stateDir(cmd) - if err != nil { - return err + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() } - state, err := workspace.NewState(dir) + ws, err := loadWorkspace(ctx, cmd) if err != nil { return err } - existing, err := state.LoadParent() - if err != nil { - return fmt.Errorf("load existing parent cred: %w", err) - } ak, _ := cmd.Flags().GetString("access-key") if ak == "" { ak = os.Getenv("DRIFT_ACCESS_KEY_ID") } - sk, _ := cmd.Flags().GetString("secret-key") + sk := os.Getenv("DRIFT_SECRET_ACCESS_KEY") if sk == "" { - sk = os.Getenv("DRIFT_SECRET_ACCESS_KEY") - } - if ak == "" || sk == "" { - return errors.New("set-parent: missing access-key or secret-key (pass flags or export DRIFT_ACCESS_KEY_ID + DRIFT_SECRET_ACCESS_KEY)") + prompted, perr := promptPassphrase("New secret access key (input hidden): ") + if perr != nil { + return perr + } + sk = prompted } - - updated := &credentials.Parent{ - Provider: existing.Provider, + res, err := ws.ParentSet(ctx, workspace.ParentSetOptions{ AccessKeyID: ak, SecretAccessKey: sk, - } - if err := state.SaveParent(updated); err != nil { - return fmt.Errorf("save parent: %w", err) + SkipVerify: false, + }) + if err != nil { + return err } fmt.Fprintf(cmd.OutOrStdout(), "✓ Parent credential updated.\n"+ - " Provider: %s (unchanged)\n"+ + " Provider: %s\n"+ " Access key: %s…\n", - updated.Provider, abbrev(ak, 6)) + res.Provider, abbrev(ak, 6)) return nil } diff --git a/internal/cli/grant.go b/internal/cli/grant.go index f72cf31..80d3b23 100644 --- a/internal/cli/grant.go +++ b/internal/cli/grant.go @@ -35,7 +35,7 @@ ssh config (no flags are forwarded — use ~/.ssh/config for identity/port).`, } cmd.Flags().StringSlice("scope", nil, "Vols to grant (comma-separated)") cmd.Flags().String("mode", "rw", "Access mode: rw or ro") - cmd.Flags().Duration("expires", 24*time.Hour, "Token TTL (≤24h recommended)") + cmd.Flags().Duration("expires", 24*time.Hour, "Token TTL (max 24h)") cmd.Flags().Bool("token-only", false, "Print ONLY the token on stdout (status to stderr)") cmd.Flags().String("out", "", "Write token to this file at chmod 0600 instead of stdout") cmd.Flags().String("ssh", "", "Deliver via SSH: runs `drift open --stdin --background` on user@host") diff --git a/internal/cli/parent.go b/internal/cli/parent.go index e7381cd..8b37451 100644 --- a/internal/cli/parent.go +++ b/internal/cli/parent.go @@ -33,9 +33,8 @@ func parentSetCmd() *cobra.Command { cmd := &cobra.Command{ Use: "set", Short: "Replace the parent S3 credential with a new AK/SK pair", - Long: `Reads the new AK/SK from --access-key + $DRIFT_SECRET_ACCESS_KEY (or ---secret-key, though passing secrets as CLI flags is visible in process -listings — env var is recommended) and overwrites the locally-stored + Long: `Reads the new AK/SK from --access-key + $DRIFT_SECRET_ACCESS_KEY (or an +interactive no-echo prompt if env var is unset) and overwrites the locally-stored parent credential. By default, the new credential is verified by issuing a HEAD against @@ -50,7 +49,6 @@ running set themselves.`, RunE: runParentSet, } cmd.Flags().String("access-key", "", "New access key ID (or $DRIFT_ACCESS_KEY_ID)") - cmd.Flags().String("secret-key", "", "New secret access key (PREFER $DRIFT_SECRET_ACCESS_KEY to keep secrets out of argv)") cmd.Flags().String("provider", "", "Override the provider id (default: keep existing — typically 'r2')") cmd.Flags().Bool("skip-verify", false, "Skip the live HEAD probe (dangerous: a wrong secret will silently brick this device's R2 access)") return cmd @@ -67,12 +65,16 @@ func runParentSet(cmd *cobra.Command, _ []string) error { if ak == "" { ak = os.Getenv("DRIFT_ACCESS_KEY_ID") } - sk, _ := cmd.Flags().GetString("secret-key") + sk := os.Getenv("DRIFT_SECRET_ACCESS_KEY") if sk == "" { - sk = os.Getenv("DRIFT_SECRET_ACCESS_KEY") + prompted, perr := promptPassphrase("New secret access key (input hidden): ") + if perr != nil { + return perr + } + sk = prompted } if ak == "" || sk == "" { - return errors.New("drift parent set: missing access-key or secret-key (pass flags or export DRIFT_ACCESS_KEY_ID + DRIFT_SECRET_ACCESS_KEY)") + return errors.New("drift parent set: missing access-key or secret-key (pass --access-key / DRIFT_ACCESS_KEY_ID and DRIFT_SECRET_ACCESS_KEY or use interactive secret prompt)") } provider, _ := cmd.Flags().GetString("provider") skipVerify, _ := cmd.Flags().GetBool("skip-verify") diff --git a/internal/token/issue.go b/internal/token/issue.go index 7c10532..eab9408 100644 --- a/internal/token/issue.go +++ b/internal/token/issue.go @@ -56,9 +56,13 @@ type IssueRequest struct { Scope []string // compartment names to grant Mode string // TokenModeRW / TokenModeRO - TTL time.Duration // <= 24h recommended + TTL time.Duration // max TokenMaxTTL } +// TokenMaxTTL caps issued bearer-token lifetime. Short-lived creds reduce +// exposure if a token string leaks from chat/log/history. +const TokenMaxTTL = 24 * time.Hour + // IssueResult is what Issue returns to the caller. Both scoped creds are // included so the CLI / workspace layer can show their expiry, etc. type IssueResult struct { @@ -220,6 +224,9 @@ func validateIssue(req IssueRequest) error { if req.TTL <= 0 { return errors.New("token: TTL must be > 0") } + if req.TTL > TokenMaxTTL { + return fmt.Errorf("token: TTL %s exceeds max %s", req.TTL, TokenMaxTTL) + } for _, name := range req.Scope { if err := domain.ValidCompartmentName(name); err != nil { return err diff --git a/internal/token/token_test.go b/internal/token/token_test.go index b99e439..6329c75 100644 --- a/internal/token/token_test.go +++ b/internal/token/token_test.go @@ -225,6 +225,7 @@ func TestIssue_validationFailures(t *testing.T) { "empty cprk": func(r *IssueRequest) { r.CPRK = nil }, "empty wid": func(r *IssueRequest) { r.WorkspaceID = "" }, "zero ttl": func(r *IssueRequest) { r.TTL = 0 }, + "ttl too long": func(r *IssueRequest) { r.TTL = TokenMaxTTL + time.Second }, "bad mode": func(r *IssueRequest) { r.Mode = "execute" }, } for name, mutate := range cases {