diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b788f62 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + # Maintain Go dependencies in the root go.mod + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + # Group minor and patch updates to reduce PR noise + groups: + go-dependencies: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # Maintain GitHub Actions in .github/workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/cmd/cloudstic/cmd_auth.go b/cmd/cloudstic/cmd_auth.go index 409c084..6733258 100644 --- a/cmd/cloudstic/cmd_auth.go +++ b/cmd/cloudstic/cmd_auth.go @@ -6,11 +6,9 @@ import ( "flag" "fmt" "os" - "path/filepath" "strings" cloudstic "github.com/cloudstic/cli" - "github.com/cloudstic/cli/internal/paths" ) func (r *runner) runAuth(ctx context.Context) int { @@ -94,9 +92,12 @@ func (r *runner) runAuthNew(ctx context.Context) int { name := fs.String("name", "", "Auth reference name") provider := fs.String("provider", "", "Auth provider: google|onedrive") googleCreds := fs.String("google-credentials", "", "Path to Google service account credentials JSON file") + googleCredsRef := fs.String("google-credentials-ref", "", "Secret reference to Google service account credentials JSON") googleTokenFile := fs.String("google-token-file", "", "Path to Google OAuth token file") + googleTokenRef := fs.String("google-token-ref", "", "Secret reference to Google OAuth token") onedriveClientID := fs.String("onedrive-client-id", "", "OneDrive OAuth client ID") onedriveTokenFile := fs.String("onedrive-token-file", "", "Path to OneDrive OAuth token file") + onedriveTokenRef := fs.String("onedrive-token-ref", "", "Secret reference to OneDrive OAuth token") _ = fs.Parse(reorderArgs(fs, os.Args[3:])) if *name == "" { @@ -129,44 +130,49 @@ func (r *runner) runAuthNew(ctx context.Context) int { auth := cloudstic.ProfileAuth{Provider: *provider} if *provider == "google" { - if *googleTokenFile == "" { + if *googleTokenFile == "" && *googleTokenRef == "" { + def := defaultAuthTokenRef("google", *name) if r.canPrompt() { - def := defaultAuthTokenPath("google", *name) - v, err := r.promptLine(ctx, "Google token file path", def) + v, err := r.promptLine(ctx, "Google token storage (file path or secret ref)", def) if err != nil { - return r.fail("Failed to read google token file path: %v", err) + return r.fail("Failed to read google token storage: %v", err) + } + if strings.Contains(v, "://") { + *googleTokenRef = v + } else { + *googleTokenFile = v } - *googleTokenFile = v - } - if *googleTokenFile == "" { - *googleTokenFile = defaultAuthTokenPath("google", *name) } - if *googleTokenFile == "" { - return r.fail("-google-token-file is required for provider=google") + if *googleTokenFile == "" && *googleTokenRef == "" { + *googleTokenRef = def } } auth.GoogleCreds = *googleCreds + auth.GoogleCredsRef = *googleCredsRef auth.GoogleTokenFile = *googleTokenFile + auth.GoogleTokenRef = *googleTokenRef } if *provider == "onedrive" { - if *onedriveTokenFile == "" { + if *onedriveTokenFile == "" && *onedriveTokenRef == "" { + def := defaultAuthTokenRef("onedrive", *name) if r.canPrompt() { - def := defaultAuthTokenPath("onedrive", *name) - v, err := r.promptLine(ctx, "OneDrive token file path", def) + v, err := r.promptLine(ctx, "OneDrive token storage (file path or secret ref)", def) if err != nil { - return r.fail("Failed to read onedrive token file path: %v", err) + return r.fail("Failed to read onedrive token storage: %v", err) + } + if strings.Contains(v, "://") { + *onedriveTokenRef = v + } else { + *onedriveTokenFile = v } - *onedriveTokenFile = v - } - if *onedriveTokenFile == "" { - *onedriveTokenFile = defaultAuthTokenPath("onedrive", *name) } - if *onedriveTokenFile == "" { - return r.fail("-onedrive-token-file is required for provider=onedrive") + if *onedriveTokenFile == "" && *onedriveTokenRef == "" { + *onedriveTokenRef = def } } auth.OneDriveClientID = *onedriveClientID auth.OneDriveTokenFile = *onedriveTokenFile + auth.OneDriveTokenRef = *onedriveTokenRef } cfg, err := loadProfilesOrInit(*profilesFile) @@ -193,19 +199,16 @@ func (r *runner) runAuthLogin(ctx context.Context) int { if err != nil { return r.fail("Failed to load profiles: %v", err) } - if *name == "" { - if r.canPrompt() { - names := sortedKeys(cfg.Auth) - picked, pickErr := r.promptSelect(ctx, "Select auth entry", names) - if pickErr != nil { - return r.fail("Failed to select auth entry: %v", pickErr) - } - *name = picked + if !r.canPrompt() { + return r.fail("usage: cloudstic auth login [-profiles-file ] ") } - if *name == "" { - return r.fail("-name is required") + names := sortedKeys(cfg.Auth) + picked, pickErr := r.promptSelect(ctx, "Select auth entry", names) + if pickErr != nil { + return r.fail("Failed to select auth entry: %v", pickErr) } + *name = picked } auth, ok := cfg.Auth[*name] @@ -213,61 +216,30 @@ func (r *runner) runAuthLogin(ctx context.Context) int { return r.fail("Unknown auth %q", *name) } - g := newAuthGlobalFlags() - - switch auth.Provider { - case "google": - googleCreds := auth.GoogleCreds - if googleCreds == "" { - googleCreds = os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") - } - src, err := initSource(ctx, initSourceOptions{ - sourceURI: "gdrive:/", - googleCreds: googleCreds, - googleTokenFile: auth.GoogleTokenFile, - globalFlags: g, - }) - if err != nil { - return r.fail("Failed to initialize Google auth source: %v", err) - } - _ = src.Info() - case "onedrive": - onedriveClientID := auth.OneDriveClientID - if onedriveClientID == "" { - onedriveClientID = os.Getenv("ONEDRIVE_CLIENT_ID") - } - src, err := initSource(ctx, initSourceOptions{ - sourceURI: "onedrive:/", - onedriveClientID: onedriveClientID, - onedriveTokenFile: auth.OneDriveTokenFile, - globalFlags: g, - }) - if err != nil { - return r.fail("Failed to initialize OneDrive auth source: %v", err) - } - _ = src.Info() - default: - return r.fail("Unsupported auth provider %q", auth.Provider) + src, err := initSource(ctx, initSourceOptions{ + sourceURI: auth.Provider + "://auth", + googleCreds: auth.GoogleCreds, + googleCredsRef: auth.GoogleCredsRef, + googleTokenFile: auth.GoogleTokenFile, + googleTokenRef: auth.GoogleTokenRef, + onedriveClientID: auth.OneDriveClientID, + onedriveTokenFile: auth.OneDriveTokenFile, + onedriveTokenRef: auth.OneDriveTokenRef, + globalFlags: &globalFlags{}, // dummy + }) + if err != nil { + return r.fail("Failed to initialize auth source: %v", err) } - _, _ = fmt.Fprintf(r.out, "Auth %q is ready\n", *name) - return 0 -} + info := src.Info() -func newAuthGlobalFlags() *globalFlags { - fs := flag.NewFlagSet("auth-login-flags", flag.ContinueOnError) - return addGlobalFlags(fs) + _, _ = fmt.Fprintf(r.out, "Successfully logged in as %s (%s)\n", info.Account, info.Type) + return 0 } -func defaultAuthTokenPath(provider, name string) string { - configDir, err := paths.ConfigDir() - if err != nil { - return "" - } - safeName := strings.ReplaceAll(strings.TrimSpace(name), " ", "-") - if safeName == "" { - safeName = "default" +func defaultAuthTokenRef(provider, name string) string { + if name == "" { + name = "default" } - file := fmt.Sprintf("%s-%s_token.json", provider, safeName) - return filepath.Join(configDir, "tokens", file) + return "config-token://" + provider + "/" + name } diff --git a/cmd/cloudstic/cmd_auth_test.go b/cmd/cloudstic/cmd_auth_test.go index f60b119..8c0a0de 100644 --- a/cmd/cloudstic/cmd_auth_test.go +++ b/cmd/cloudstic/cmd_auth_test.go @@ -1,11 +1,14 @@ package main import ( + "bufio" "context" "os" "path/filepath" "strings" "testing" + + cloudstic "github.com/cloudstic/cli" ) func TestRunAuthNewAndListAndShow(t *testing.T) { @@ -199,28 +202,18 @@ func TestRunAuthList_MissingFile(t *testing.T) { } } -func TestDefaultAuthTokenPath(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CLOUDSTIC_CONFIG_DIR", tmpDir) - - got := defaultAuthTokenPath("google", "work") - want := filepath.Join("tokens", "google-work_token.json") - if !strings.HasSuffix(got, want) { - t.Fatalf("expected path ending with %q, got %q", want, got) +func TestDefaultAuthTokenRef(t *testing.T) { + if got := defaultAuthTokenRef("google", "work"); got != "config-token://google/work" { + t.Fatalf("unexpected ref: %q", got) } - - // Empty name should use "default" - got = defaultAuthTokenPath("google", "") - want = filepath.Join("tokens", "google-default_token.json") - if !strings.HasSuffix(got, want) { - t.Fatalf("expected path ending with %q, got %q", want, got) + if got := defaultAuthTokenRef("google", ""); got != "config-token://google/default" { + t.Fatalf("unexpected default ref for empty name: %q", got) } } -func TestRunAuthNew_OneDriveDerivesTokenFile(t *testing.T) { +func TestRunAuthNew_OneDriveDerivesTokenRef(t *testing.T) { tmpDir := t.TempDir() profilesPath := filepath.Join(tmpDir, "profiles.yaml") - t.Setenv("CLOUDSTIC_CONFIG_DIR", tmpDir) os.Args = []string{ "cloudstic", "auth", "new", @@ -230,7 +223,7 @@ func TestRunAuthNew_OneDriveDerivesTokenFile(t *testing.T) { } var out strings.Builder var errOut strings.Builder - r := &runner{out: &out, errOut: &errOut} + r := &runner{out: &out, errOut: &errOut, noPrompt: true} if code := r.runAuth(context.Background()); code != 0 { t.Fatalf("auth new failed: %s", errOut.String()) } @@ -239,20 +232,19 @@ func TestRunAuthNew_OneDriveDerivesTokenFile(t *testing.T) { if err != nil { t.Fatalf("read profiles file: %v", err) } - if !strings.Contains(string(raw), "onedrive-od-work_token.json") { - t.Fatalf("expected derived onedrive token file path in profiles file:\n%s", string(raw)) + if !strings.Contains(string(raw), "onedrive_token_ref: config-token://onedrive/od-work") { + t.Fatalf("expected derived onedrive token ref in profiles file:\n%s", string(raw)) } } -func TestRunAuthNew_DerivesDefaultTokenFile(t *testing.T) { +func TestRunAuthNew_DerivesDefaultTokenRef(t *testing.T) { tmpDir := t.TempDir() profilesPath := filepath.Join(tmpDir, "profiles.yaml") - t.Setenv("CLOUDSTIC_CONFIG_DIR", tmpDir) os.Args = []string{"cloudstic", "auth", "new", "-profiles-file", profilesPath, "-name", "g", "-provider", "google"} var out strings.Builder var errOut strings.Builder - r := &runner{out: &out, errOut: &errOut} + r := &runner{out: &out, errOut: &errOut, noPrompt: true} if code := r.runAuth(context.Background()); code != 0 { t.Fatalf("auth new failed: %s", errOut.String()) } @@ -260,7 +252,43 @@ func TestRunAuthNew_DerivesDefaultTokenFile(t *testing.T) { if err != nil { t.Fatalf("read profiles file: %v", err) } - if !strings.Contains(string(raw), "google-g_token.json") { - t.Fatalf("expected derived token file path in profiles file:\n%s", string(raw)) + if !strings.Contains(string(raw), "google_token_ref: config-token://google/g") { + t.Fatalf("expected derived token ref in profiles file:\n%s", string(raw)) + } +} + +func TestPromptAuthSelection_DerivesTokenRef(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CLOUDSTIC_CONFIG_DIR", tmpDir) + cfg := &cloudstic.ProfilesConfig{ + Auth: make(map[string]cloudstic.ProfileAuth), + } + + r := &runner{ + out: &strings.Builder{}, + errOut: &strings.Builder{}, + // Mock prompt interactions: + // 1. Select option: "Create new auth" + // 2. Auth name: "my-google" + // 3. Token storage: use default (config-token://google/my-google) + lineIn: bufio.NewReader(strings.NewReader("1\nmy-google\n\n")), + } + + ctx := context.Background() + name, code := r.promptAuthSelection(ctx, cfg, "google", "test-profile") + if code != 0 { + t.Fatalf("promptAuthSelection failed with code %d", code) + } + if name != "my-google" { + t.Fatalf("expected name 'my-google', got %q", name) + } + + auth, ok := cfg.Auth["my-google"] + if !ok { + t.Fatal("auth entry not created in config") + } + expectedRef := "config-token://google/my-google" + if auth.GoogleTokenRef != expectedRef { + t.Fatalf("expected token ref %q, got %q", expectedRef, auth.GoogleTokenRef) } } diff --git a/cmd/cloudstic/cmd_backup.go b/cmd/cloudstic/cmd_backup.go index e71b9c5..d61fbe4 100644 --- a/cmd/cloudstic/cmd_backup.go +++ b/cmd/cloudstic/cmd_backup.go @@ -15,6 +15,7 @@ import ( cloudstic "github.com/cloudstic/cli" "github.com/cloudstic/cli/internal/engine" "github.com/cloudstic/cli/internal/paths" + "github.com/cloudstic/cli/internal/secretref" "github.com/cloudstic/cli/pkg/source" ) @@ -30,9 +31,12 @@ type backupArgs struct { skipNativeFiles bool volumeUUID string googleCreds string + googleCredsRef string googleTokenFile string + googleTokenRef string onedriveClientID string onedriveTokenFile string + onedriveTokenRef string skipMode bool skipFlags bool skipXattrs bool @@ -54,9 +58,12 @@ func parseBackupArgs() *backupArgs { excludeFile := fs.String("exclude-file", "", "Path to file with exclude patterns (one per line, gitignore syntax)") volumeUUID := fs.String("volume-uuid", envDefault("CLOUDSTIC_VOLUME_UUID", ""), "Override volume UUID for local source (enables cross-machine incremental backup)") googleCreds := fs.String("google-credentials", envDefault("GOOGLE_APPLICATION_CREDENTIALS", ""), "Path to Google service account credentials JSON file") + googleCredsRef := fs.String("google-credentials-ref", "", "Secret reference to Google service account credentials JSON") googleTokenFile := fs.String("google-token-file", envDefault("GOOGLE_TOKEN_FILE", ""), "Path to Google OAuth token file") + googleTokenRef := fs.String("google-token-ref", "", "Secret reference to Google OAuth token") onedriveClientID := fs.String("onedrive-client-id", envDefault("ONEDRIVE_CLIENT_ID", ""), "OneDrive OAuth client ID") onedriveTokenFile := fs.String("onedrive-token-file", envDefault("ONEDRIVE_TOKEN_FILE", ""), "Path to OneDrive OAuth token file") + onedriveTokenRef := fs.String("onedrive-token-ref", "", "Secret reference to OneDrive OAuth token") skipMode := fs.Bool("skip-mode", false, "Skip POSIX mode, uid, gid, btime, and flags collection") skipFlags := fs.Bool("skip-flags", false, "Skip file flags collection") skipXattrs := fs.Bool("skip-xattrs", false, "Skip extended attribute collection") @@ -74,9 +81,12 @@ func parseBackupArgs() *backupArgs { a.excludeFile = *excludeFile a.volumeUUID = *volumeUUID a.googleCreds = *googleCreds + a.googleCredsRef = *googleCredsRef a.googleTokenFile = *googleTokenFile + a.googleTokenRef = *googleTokenRef a.onedriveClientID = *onedriveClientID a.onedriveTokenFile = *onedriveTokenFile + a.onedriveTokenRef = *onedriveTokenRef a.skipMode = *skipMode a.skipFlags = *skipFlags a.skipXattrs = *skipXattrs @@ -133,9 +143,12 @@ func (r *runner) runSingleBackup(a *backupArgs) int { skipNativeFiles: a.skipNativeFiles, volumeUUID: a.volumeUUID, googleCreds: a.googleCreds, + googleCredsRef: a.googleCredsRef, googleTokenFile: a.googleTokenFile, + googleTokenRef: a.googleTokenRef, onedriveClientID: a.onedriveClientID, onedriveTokenFile: a.onedriveTokenFile, + onedriveTokenRef: a.onedriveTokenRef, skipMode: a.skipMode, skipFlags: a.skipFlags, skipXattrs: a.skipXattrs, @@ -321,15 +334,24 @@ func mergeProfileBackupArgs(base *backupArgs, profileName string, p cloudstic.Ba if !a.flagsSet["google-credentials"] && p.GoogleCreds != "" { a.googleCreds = p.GoogleCreds } + if !a.flagsSet["google-credentials-ref"] && p.GoogleCredsRef != "" { + a.googleCredsRef = p.GoogleCredsRef + } if !a.flagsSet["google-token-file"] && p.GoogleTokenFile != "" { a.googleTokenFile = p.GoogleTokenFile } + if !a.flagsSet["google-token-ref"] && p.GoogleTokenRef != "" { + a.googleTokenRef = p.GoogleTokenRef + } if !a.flagsSet["onedrive-client-id"] && p.OneDriveClientID != "" { a.onedriveClientID = p.OneDriveClientID } if !a.flagsSet["onedrive-token-file"] && p.OneDriveTokenFile != "" { a.onedriveTokenFile = p.OneDriveTokenFile } + if !a.flagsSet["onedrive-token-ref"] && p.OneDriveTokenRef != "" { + a.onedriveTokenRef = p.OneDriveTokenRef + } if len(a.tags) == 0 && len(p.Tags) > 0 { a.tags = append(stringArrayFlags{}, p.Tags...) @@ -400,9 +422,15 @@ func applyProfileAuthToBackupArgs(a *backupArgs, auth cloudstic.ProfileAuth) err if !a.flagsSet["google-credentials"] && auth.GoogleCreds != "" { a.googleCreds = auth.GoogleCreds } + if !a.flagsSet["google-credentials-ref"] && auth.GoogleCredsRef != "" { + a.googleCredsRef = auth.GoogleCredsRef + } if !a.flagsSet["google-token-file"] && auth.GoogleTokenFile != "" { a.googleTokenFile = auth.GoogleTokenFile } + if !a.flagsSet["google-token-ref"] && auth.GoogleTokenRef != "" { + a.googleTokenRef = auth.GoogleTokenRef + } } if requiredProvider == "onedrive" { @@ -412,6 +440,9 @@ func applyProfileAuthToBackupArgs(a *backupArgs, auth cloudstic.ProfileAuth) err if !a.flagsSet["onedrive-token-file"] && auth.OneDriveTokenFile != "" { a.onedriveTokenFile = auth.OneDriveTokenFile } + if !a.flagsSet["onedrive-token-ref"] && auth.OneDriveTokenRef != "" { + a.onedriveTokenRef = auth.OneDriveTokenRef + } } return nil @@ -607,9 +638,12 @@ type initSourceOptions struct { skipNativeFiles bool volumeUUID string googleCreds string + googleCredsRef string googleTokenFile string + googleTokenRef string onedriveClientID string onedriveTokenFile string + onedriveTokenRef string skipMode bool skipFlags bool skipXattrs bool @@ -624,6 +658,8 @@ func initSource(ctx context.Context, opts initSourceOptions) (source.Source, err return nil, err } + resolver := secretref.NewDefaultResolver() + switch uri.scheme { case "local": localOpts := []source.LocalOption{source.WithLocalExcludePatterns(opts.excludePatterns)} @@ -656,8 +692,11 @@ func initSource(ctx context.Context, opts initSourceOptions) (source.Source, err return nil, err } gdriveOpts := []source.GDriveOption{ + source.WithResolver(resolver), source.WithCredsPath(opts.googleCreds), + source.WithCredsRef(opts.googleCredsRef), source.WithTokenPath(tokenPath), + source.WithTokenRef(opts.googleTokenRef), source.WithDriveName(uri.host), source.WithRootPath(uri.path), source.WithGDriveExcludePatterns(opts.excludePatterns), @@ -672,8 +711,11 @@ func initSource(ctx context.Context, opts initSourceOptions) (source.Source, err return nil, err } gdriveOpts := []source.GDriveOption{ + source.WithResolver(resolver), source.WithCredsPath(opts.googleCreds), + source.WithCredsRef(opts.googleCredsRef), source.WithTokenPath(tokenPath), + source.WithTokenRef(opts.googleTokenRef), source.WithDriveName(uri.host), source.WithRootPath(uri.path), source.WithGDriveExcludePatterns(opts.excludePatterns), @@ -688,8 +730,10 @@ func initSource(ctx context.Context, opts initSourceOptions) (source.Source, err return nil, err } return source.NewOneDriveSource(ctx, + source.WithOneDriveResolver(resolver), source.WithOneDriveClientID(opts.onedriveClientID), source.WithOneDriveTokenPath(tokenPath), + source.WithOneDriveTokenRef(opts.onedriveTokenRef), source.WithOneDriveDriveName(uri.host), source.WithOneDriveRootPath(uri.path), source.WithOneDriveExcludePatterns(opts.excludePatterns), @@ -700,8 +744,10 @@ func initSource(ctx context.Context, opts initSourceOptions) (source.Source, err return nil, err } return source.NewOneDriveChangeSource(ctx, + source.WithOneDriveResolver(resolver), source.WithOneDriveClientID(opts.onedriveClientID), source.WithOneDriveTokenPath(tokenPath), + source.WithOneDriveTokenRef(opts.onedriveTokenRef), source.WithOneDriveDriveName(uri.host), source.WithOneDriveRootPath(uri.path), source.WithOneDriveExcludePatterns(opts.excludePatterns), diff --git a/cmd/cloudstic/cmd_profile.go b/cmd/cloudstic/cmd_profile.go index aa2e88a..c3a9b2c 100644 --- a/cmd/cloudstic/cmd_profile.go +++ b/cmd/cloudstic/cmd_profile.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "sort" + "strings" cloudstic "github.com/cloudstic/cli" "github.com/cloudstic/cli/internal/paths" @@ -441,21 +442,34 @@ func (r *runner) promptAuthSelection(ctx context.Context, cfg *cloudstic.Profile if refName == "" { return "", r.fail("Auth reference name is required") } + if err := validateRefName("auth", refName); err != nil { + return "", r.fail("%v", err) + } - tokenFile, err := r.promptLine(ctx, "Token file path", defaultAuthTokenPath(provider, refName)) + defTokenRef := "config-token://" + provider + "/" + refName + tokenStorage, err := r.promptLine(ctx, "Token storage (file path or secret ref)", defTokenRef) if err != nil { - return "", r.fail("Failed to read token file path: %v", err) + return "", r.fail("Failed to read token storage: %v", err) } - if tokenFile == "" { - return "", r.fail("Token file path is required") + if tokenStorage == "" { + tokenStorage = defTokenRef } auth := cloudstic.ProfileAuth{Provider: provider} - switch provider { - case "google": - auth.GoogleTokenFile = tokenFile - case "onedrive": - auth.OneDriveTokenFile = tokenFile + if strings.Contains(tokenStorage, "://") { + switch provider { + case "google": + auth.GoogleTokenRef = tokenStorage + case "onedrive": + auth.OneDriveTokenRef = tokenStorage + } + } else { + switch provider { + case "google": + auth.GoogleTokenFile = tokenStorage + case "onedrive": + auth.OneDriveTokenFile = tokenStorage + } } cfg.Auth[refName] = auth return refName, 0 diff --git a/cmd/cloudstic/config_tables.go b/cmd/cloudstic/config_tables.go index 59bad21..806a645 100644 --- a/cmd/cloudstic/config_tables.go +++ b/cmd/cloudstic/config_tables.go @@ -124,13 +124,13 @@ func profileHealth(cfg *cloudstic.ProfilesConfig, p cloudstic.BackupProfile) (st func authHealth(auth cloudstic.ProfileAuth) (string, []string) { switch auth.Provider { case "google": - if auth.GoogleTokenFile == "" { - return "warning", []string{"missing token file"} + if auth.GoogleTokenFile == "" && auth.GoogleTokenRef == "" { + return "warning", []string{"missing token storage"} } return "ready", nil case "onedrive": - if auth.OneDriveTokenFile == "" { - return "warning", []string{"missing token file"} + if auth.OneDriveTokenFile == "" && auth.OneDriveTokenRef == "" { + return "warning", []string{"missing token storage"} } return "ready", nil default: @@ -263,9 +263,15 @@ func dashIfEmpty(v string) string { } func authTokenPath(auth cloudstic.ProfileAuth) string { + if auth.GoogleTokenRef != "" { + return auth.GoogleTokenRef + } if auth.GoogleTokenFile != "" { return auth.GoogleTokenFile } + if auth.OneDriveTokenRef != "" { + return auth.OneDriveTokenRef + } if auth.OneDriveTokenFile != "" { return auth.OneDriveTokenFile } @@ -352,23 +358,32 @@ func (r *runner) renderAuthShow(cfg *cloudstic.ProfilesConfig, name string, auth renderSectionHeading(r.out, fmt.Sprintf("Auth %s", name), -1) renderKVTable(r.out, appendWarningRow([]table.Row{ {"Provider", auth.Provider}, - {"Token File", authTokenPath(auth)}, + {"Token Storage", authTokenPath(auth)}, {"Status", statusLabel(status)}, }, warnings)) providerRows := []table.Row{} if auth.GoogleCreds != "" { - providerRows = append(providerRows, table.Row{"Google Credentials", auth.GoogleCreds}) + providerRows = append(providerRows, table.Row{"Google Credentials File", auth.GoogleCreds}) + } + if auth.GoogleCredsRef != "" { + providerRows = append(providerRows, table.Row{"Google Credentials Ref", auth.GoogleCredsRef}) } if auth.GoogleTokenFile != "" { providerRows = append(providerRows, table.Row{"Google Token File", auth.GoogleTokenFile}) } + if auth.GoogleTokenRef != "" { + providerRows = append(providerRows, table.Row{"Google Token Ref", auth.GoogleTokenRef}) + } if auth.OneDriveClientID != "" { providerRows = append(providerRows, table.Row{"OneDrive Client ID", auth.OneDriveClientID}) } if auth.OneDriveTokenFile != "" { providerRows = append(providerRows, table.Row{"OneDrive Token File", auth.OneDriveTokenFile}) } + if auth.OneDriveTokenRef != "" { + providerRows = append(providerRows, table.Row{"OneDrive Token Ref", auth.OneDriveTokenRef}) + } if len(providerRows) > 0 { renderSectionHeading(r.out, "Provider Details", -1) renderKVTable(r.out, providerRows) @@ -449,17 +464,26 @@ func (r *runner) renderProfileShow(cfg *cloudstic.ProfilesConfig, name string, p optionRows = append(optionRows, table.Row{"Volume UUID", p.VolumeUUID}) } if p.GoogleCreds != "" { - optionRows = append(optionRows, table.Row{"Google Credentials", p.GoogleCreds}) + optionRows = append(optionRows, table.Row{"Google Credentials File", p.GoogleCreds}) + } + if p.GoogleCredsRef != "" { + optionRows = append(optionRows, table.Row{"Google Credentials Ref", p.GoogleCredsRef}) } if p.GoogleTokenFile != "" { optionRows = append(optionRows, table.Row{"Google Token File", p.GoogleTokenFile}) } + if p.GoogleTokenRef != "" { + optionRows = append(optionRows, table.Row{"Google Token Ref", p.GoogleTokenRef}) + } if p.OneDriveClientID != "" { optionRows = append(optionRows, table.Row{"OneDrive Client ID", p.OneDriveClientID}) } if p.OneDriveTokenFile != "" { optionRows = append(optionRows, table.Row{"OneDrive Token File", p.OneDriveTokenFile}) } + if p.OneDriveTokenRef != "" { + optionRows = append(optionRows, table.Row{"OneDrive Token Ref", p.OneDriveTokenRef}) + } renderSectionHeading(r.out, "Options", -1) renderKVTable(r.out, optionRows) diff --git a/docs/encryption.md b/docs/encryption.md index 262278a..92e5707 100644 --- a/docs/encryption.md +++ b/docs/encryption.md @@ -102,6 +102,43 @@ passes through any object under the `keys/` prefix without encrypting or decrypting it, avoiding the chicken-and-egg problem of needing the encryption key to read the encryption key. +## Auth Material Encryption + +In addition to repository data, Cloudstic provides at-rest protection for +sensitive authentication material (like OAuth tokens) stored locally on the +client machine via the `config-token://` reference scheme. + +### Managed Token Storage + +When using `config-token:///`, Cloudstic manages the lifecycle +and security of the token blob: + +- **Location**: Tokens are stored in the app's config directory (e.g., + `~/.config/cloudstic/tokens/`). +- **Encryption**: Blobs are encrypted using AES-256-GCM before being written + to disk. +- **Key Derivation**: The encryption key is unique to the machine and user. + It is derived from a persistent random salt file (`auth_salt`), a + hardware-specific Machine ID, and a stable per-user OS identifier + (UID on Unix-like systems, the platform user identifier elsewhere). +- **Atomic Updates**: To prevent corruption during OAuth token refreshes, + updates are performed atomically using a write-to-temporary-then-rename + pattern. +- **Permissions**: All managed token files and directories are restricted to + the current user (`0600` for files, `0700` for directories). + +### Native Keychain Integration + +On supported platforms (e.g., macOS), Cloudstic can store auth blobs directly in +the OS-native secure store using the `keychain://` scheme. In this mode, the OS +handles encryption and access control, providing the highest level of security. + +### Unencrypted Fallback (Stateless/Cloud) + +For environments where local encryption is not desired (e.g., when secrets are +already managed by Kubernetes or a Cloud provider), the `file://` scheme can +be used to read and write auth material in its raw, unencrypted form. + ### Key derivation The master key is not used directly for encryption. Instead, HKDF-SHA256 @@ -271,15 +308,23 @@ Repository encryption key slots and profile credentials are separate concerns: `profiles.yaml` should store secret references, not secret values. Supported reference schemes: -- `env://VAR_NAME` -- `keychain://service/account` (macOS) +- `env://VAR_NAME` (Stateless environments, CI/CD) +- `keychain://service/account` (OS-native secure store) +- `config-token://provider/name` (Encrypted local file managed by Cloudstic) +- `file:///path/to/secret` (Raw local file) -`wincred://...` (Windows) and `secret-service://...` (Linux) are planned but not -yet available in the default CLI resolver. +`wincred://...` (Windows) and `secret-service://...` (Linux) are also supported +as native backends. Examples: ```yaml +auth: + google-work: + provider: google + google_token_ref: config-token://google/google-work + google_credentials_ref: keychain://cloudstic/auth/google-creds + stores: prod: uri: s3:my-bucket/cloudstic diff --git a/docs/user-guide.md b/docs/user-guide.md index 01d8948..05a80da 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -630,16 +630,53 @@ cloudstic auth new \ -onedrive-token-file ~/.config/cloudstic/tokens/ms-personal.json ``` -If token file flags are omitted, Cloudstic derives a default token path from -the auth name under `/tokens/`. +If token storage flags are omitted, Cloudstic defaults to a managed encrypted +reference under `config-token:///`. If required flags are omitted and you are in an interactive terminal, `auth new` prompts for missing values. +#### Secret References and Secure Storage + +Cloudstic uses **Secret References** to avoid storing sensitive credentials +like OAuth tokens or service account JSON in plaintext within your +`profiles.yaml`. + +When creating an auth entry, you can specify a reference instead of a raw +file path: + +```bash +# Store Google token in macOS Keychain (secure) +cloudstic auth new -name google-work -provider google \ + -google-token-ref keychain://cloudstic/auth/google-work + +# Store Google token in an encrypted local file (managed by Cloudstic) +cloudstic auth new -name google-work -provider google \ + -google-token-ref config-token://google/google-work + +# Use a raw file (e.g. for Kubernetes mounted secrets) +cloudstic auth new -name google-work -provider google \ + -google-token-ref file:///etc/secrets/google-token.json +``` + +**Supported Schemes:** + +| Scheme | Description | Use Case | +| :--- | :--- | :--- | +| `keychain://` | macOS Keychain blob storage | Personal macOS workstations | +| `wincred://` | Windows Credential Manager secret storage | Personal Windows workstations | +| `secret-service://` | Linux Secret Service secret storage | Linux desktops with a keyring | +| `config-token://` | Encrypted local file | Headless servers, Linux desktops | +| `file://` | Raw local file | CI/CD, Kubernetes, Docker | + +For `config-token://`, Cloudstic automatically encrypts the token at rest +using a key derived from your machine ID and user ID. See the +[Encryption Design](./encryption.md#auth-material-encryption) for more details. + #### auth login -Trigger OAuth login for an auth entry and save token in its configured token -file. +Trigger OAuth login for an auth entry and save the token in its configured +token storage. ```bash cloudstic auth login -name google-work diff --git a/internal/engine/profiles.go b/internal/engine/profiles.go index b348d17..cbd092b 100644 --- a/internal/engine/profiles.go +++ b/internal/engine/profiles.go @@ -52,9 +52,12 @@ type BackupProfile struct { SkipNativeFiles bool `yaml:"skip_native_files,omitempty"` VolumeUUID string `yaml:"volume_uuid,omitempty"` GoogleCreds string `yaml:"google_credentials,omitempty"` + GoogleCredsRef string `yaml:"google_credentials_ref,omitempty"` GoogleTokenFile string `yaml:"google_token_file,omitempty"` + GoogleTokenRef string `yaml:"google_token_ref,omitempty"` OneDriveClientID string `yaml:"onedrive_client_id,omitempty"` OneDriveTokenFile string `yaml:"onedrive_token_file,omitempty"` + OneDriveTokenRef string `yaml:"onedrive_token_ref,omitempty"` Enabled *bool `yaml:"enabled,omitempty"` } @@ -62,9 +65,12 @@ type BackupProfile struct { type ProfileAuth struct { Provider string `yaml:"provider"` // google | onedrive GoogleCreds string `yaml:"google_credentials,omitempty"` + GoogleCredsRef string `yaml:"google_credentials_ref,omitempty"` GoogleTokenFile string `yaml:"google_token_file,omitempty"` + GoogleTokenRef string `yaml:"google_token_ref,omitempty"` OneDriveClientID string `yaml:"onedrive_client_id,omitempty"` OneDriveTokenFile string `yaml:"onedrive_token_file,omitempty"` + OneDriveTokenRef string `yaml:"onedrive_token_ref,omitempty"` } // IsEnabled reports whether the profile should be included in -all-profiles. @@ -96,37 +102,59 @@ func normalizeProfilesConfig(cfg *ProfilesConfig) *ProfilesConfig { func validateProfilesConfig(cfg *ProfilesConfig) error { for storeName, s := range cfg.Stores { - if err := validateSecretRef(storeName, "password_secret", s.PasswordSecret); err != nil { + if err := validateSecretRef("store", storeName, "password_secret", s.PasswordSecret); err != nil { return err } - if err := validateSecretRef(storeName, "encryption_key_secret", s.EncryptionKeySecret); err != nil { + if err := validateSecretRef("store", storeName, "encryption_key_secret", s.EncryptionKeySecret); err != nil { return err } - if err := validateSecretRef(storeName, "recovery_key_secret", s.RecoveryKeySecret); err != nil { + if err := validateSecretRef("store", storeName, "recovery_key_secret", s.RecoveryKeySecret); err != nil { return err } - if err := validateSecretRef(storeName, "s3_access_key_secret", s.S3AccessKeySecret); err != nil { + if err := validateSecretRef("store", storeName, "s3_access_key_secret", s.S3AccessKeySecret); err != nil { return err } - if err := validateSecretRef(storeName, "s3_secret_key_secret", s.S3SecretKeySecret); err != nil { + if err := validateSecretRef("store", storeName, "s3_secret_key_secret", s.S3SecretKeySecret); err != nil { return err } - if err := validateSecretRef(storeName, "store_sftp_password_secret", s.StoreSFTPPasswordSecret); err != nil { + if err := validateSecretRef("store", storeName, "store_sftp_password_secret", s.StoreSFTPPasswordSecret); err != nil { return err } - if err := validateSecretRef(storeName, "store_sftp_key_secret", s.StoreSFTPKeySecret); err != nil { + if err := validateSecretRef("store", storeName, "store_sftp_key_secret", s.StoreSFTPKeySecret); err != nil { + return err + } + } + for authName, a := range cfg.Auth { + if err := validateSecretRef("auth", authName, "google_credentials_ref", a.GoogleCredsRef); err != nil { + return err + } + if err := validateSecretRef("auth", authName, "google_token_ref", a.GoogleTokenRef); err != nil { + return err + } + if err := validateSecretRef("auth", authName, "onedrive_token_ref", a.OneDriveTokenRef); err != nil { + return err + } + } + for profileName, p := range cfg.Profiles { + if err := validateSecretRef("profile", profileName, "google_credentials_ref", p.GoogleCredsRef); err != nil { + return err + } + if err := validateSecretRef("profile", profileName, "google_token_ref", p.GoogleTokenRef); err != nil { + return err + } + if err := validateSecretRef("profile", profileName, "onedrive_token_ref", p.OneDriveTokenRef); err != nil { return err } } return nil } -func validateSecretRef(storeName, fieldName, ref string) error { +func validateSecretRef(entryType, entryName, fieldName, ref string) error { if ref == "" { return nil } if _, err := secretref.Parse(ref); err != nil { - return fmt.Errorf("store %q field %q: %w", storeName, fieldName, err) + return fmt.Errorf("%s %q field %q: %w", entryType, entryName, fieldName, err) } return nil } diff --git a/internal/paths/paths.go b/internal/paths/paths.go index a3c1bdb..0f33d09 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "path/filepath" + "runtime" + "strings" ) const appName = "cloudstic" @@ -36,9 +38,70 @@ func TokenPath(filename string) (string, error) { return filepath.Join(dir, filename), nil } +// MachineID returns a unique identifier for the current machine. +// It tries to read from common system files. +func MachineID() string { + // 1. Linux/BSD + for _, path := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} { + if b, err := os.ReadFile(path); err == nil { + if id := strings.TrimSpace(string(b)); id != "" { + return id + } + } + } + // 2. Fallback to hostname if nothing else works + host, _ := os.Hostname() + return strings.TrimSpace(host) +} + func ensureDir(dir string) (string, error) { if err := os.MkdirAll(dir, 0700); err != nil { return "", fmt.Errorf("create config directory %s: %w", dir, err) } return dir, nil } + +// SaveAtomic writes data to a temporary file in the target directory and +// atomically renames it to path to prevent file corruption during crashes. +// It ensures 0600 permissions on the final file. +func SaveAtomic(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + + tmp, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp") + if err != nil { + return err + } + defer func() { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + }() + + // No need to Chmod as CreateTemp already creates 0600 on Unix systems. + + if _, err := tmp.Write(data); err != nil { + return err + } + + if err := tmp.Sync(); err != nil { + return err + } + + if err := tmp.Close(); err != nil { + return err + } + + return replaceFile(tmp.Name(), path) +} + +func replaceFile(src, dst string) error { + if runtime.GOOS != "windows" { + return os.Rename(src, dst) + } + if err := os.Remove(dst); err != nil && !os.IsNotExist(err) { + return err + } + return os.Rename(src, dst) +} diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go index 4c9eac2..f0000e8 100644 --- a/internal/paths/paths_test.go +++ b/internal/paths/paths_test.go @@ -3,6 +3,7 @@ package paths import ( "os" "path/filepath" + "runtime" "testing" ) @@ -91,3 +92,29 @@ func TestTokenPath_ReturnsError(t *testing.T) { t.Error("expected error when config dir cannot be created") } } + +func TestSaveAtomic_ReplacesExistingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "token.json") + if err := os.WriteFile(path, []byte("old"), 0600); err != nil { + t.Fatal(err) + } + if err := SaveAtomic(path, []byte("new")); err != nil { + t.Fatalf("SaveAtomic failed: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if string(got) != "new" { + t.Fatalf("got %q want %q", got, "new") + } + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat failed: %v", err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("got perms %o want 600", info.Mode().Perm()) + } + } +} diff --git a/internal/secretref/file_backend.go b/internal/secretref/file_backend.go new file mode 100644 index 0000000..325c868 --- /dev/null +++ b/internal/secretref/file_backend.go @@ -0,0 +1,282 @@ +package secretref + +import ( + "context" + "fmt" + "os" + "os/user" + pathpkg "path" + "path/filepath" + "strings" + + "github.com/cloudstic/cli/internal/logger" + "github.com/cloudstic/cli/internal/paths" + "github.com/cloudstic/cli/pkg/crypto" +) + +// FileBackend handles file:// references. +type FileBackend struct{} + +func NewFileBackend() *FileBackend { + return &FileBackend{} +} + +func (b *FileBackend) Resolve(ctx context.Context, ref Ref) (string, error) { + data, err := b.LoadBlob(ctx, ref) + if err != nil { + return "", err + } + return string(data), nil +} + +func (b *FileBackend) LoadBlob(_ context.Context, ref Ref) ([]byte, error) { + path := filepath.Clean(ref.Path) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, errorf(KindNotFound, ref.Raw, "file does not exist", err) + } + return nil, errorf(KindBackendUnavailable, ref.Raw, "failed to read file", err) + } + return data, nil +} + +func (b *FileBackend) SaveBlob(_ context.Context, ref Ref, data []byte) error { + path := filepath.Clean(ref.Path) + if err := paths.SaveAtomic(path, data); err != nil { + return errorf(KindBackendUnavailable, ref.Raw, "failed to save file atomically", err) + } + return nil +} + +func (b *FileBackend) DeleteBlob(_ context.Context, ref Ref) error { + path := filepath.Clean(ref.Path) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return errorf(KindBackendUnavailable, ref.Raw, "failed to delete file", err) + } + return nil +} + +func (b *FileBackend) Scheme() string { return "file" } + +func (b *FileBackend) DisplayName() string { return "Local file" } + +func (b *FileBackend) WriteSupported() bool { return true } + +func (b *FileBackend) DefaultRef(name, account string) string { + _ = account + return "file:///tmp/cloudstic-secret-" + name +} + +func (b *FileBackend) Exists(_ context.Context, ref Ref) (bool, error) { + _, err := os.Stat(filepath.Clean(ref.Path)) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func (b *FileBackend) Store(ctx context.Context, ref Ref, value string) error { + return b.SaveBlob(ctx, ref, []byte(value)) +} + +// ConfigTokenBackend handles config-token:/// references. +// It stores tokens in the app's managed config directory. +type ConfigTokenBackend struct{} + +func NewConfigTokenBackend() *ConfigTokenBackend { + return &ConfigTokenBackend{} +} + +func (b *ConfigTokenBackend) Resolve(ctx context.Context, ref Ref) (string, error) { + data, err := b.LoadBlob(ctx, ref) + if err != nil { + return "", err + } + return string(data), nil +} + +func (b *ConfigTokenBackend) LoadBlob(_ context.Context, ref Ref) ([]byte, error) { + path, err := b.resolvePath(ref) + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, errorf(KindNotFound, ref.Raw, "managed token file does not exist", err) + } + return nil, errorf(KindBackendUnavailable, ref.Raw, "failed to read managed token file", err) + } + + key, err := b.getEncryptionKey() + if err != nil { + return nil, errorf(KindBackendUnavailable, ref.Raw, "failed to derive encryption key", err) + } + + decrypted, err := crypto.Decrypt(data, key) + if err != nil { + // Fallback for unencrypted files (compatibility with existing tokens before RFC 0016) + if !crypto.IsEncrypted(data) { + logger.Debugf("decryption failed for %q, but data is not encrypted; falling back to plaintext", ref.Raw) + return data, nil + } + return nil, errorf(KindBackendUnavailable, ref.Raw, "failed to decrypt managed token file", err) + } + return decrypted, nil +} + +func (b *ConfigTokenBackend) SaveBlob(ctx context.Context, ref Ref, data []byte) error { + path, err := b.resolvePath(ref) + if err != nil { + return err + } + + key, err := b.getEncryptionKey() + if err != nil { + return errorf(KindBackendUnavailable, ref.Raw, "failed to derive encryption key", err) + } + + encrypted, err := crypto.Encrypt(data, key) + if err != nil { + return errorf(KindBackendUnavailable, ref.Raw, "failed to encrypt token", err) + } + + if err := paths.SaveAtomic(path, encrypted); err != nil { + return errorf(KindBackendUnavailable, ref.Raw, "failed to save managed token file atomically", err) + } + return nil +} + +func (b *ConfigTokenBackend) DeleteBlob(ctx context.Context, ref Ref) error { + path, err := b.resolvePath(ref) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return errorf(KindBackendUnavailable, ref.Raw, "failed to delete managed token file", err) + } + return nil +} + +func (b *ConfigTokenBackend) Scheme() string { return "config-token" } + +func (b *ConfigTokenBackend) DisplayName() string { return "App-managed token (encrypted fallback)" } + +func (b *ConfigTokenBackend) WriteSupported() bool { return true } + +func (b *ConfigTokenBackend) DefaultRef(name, account string) string { + provider := account + if provider == "" { + provider = "google" + } + return "config-token://" + provider + "/" + name +} + +func (b *ConfigTokenBackend) Exists(ctx context.Context, ref Ref) (bool, error) { + path, err := b.resolvePath(ref) + if err != nil { + return false, err + } + _, err = os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func (b *ConfigTokenBackend) Store(ctx context.Context, ref Ref, value string) error { + return b.SaveBlob(ctx, ref, []byte(value)) +} + +func (b *ConfigTokenBackend) getEncryptionKey() ([]byte, error) { + configDir, err := paths.ConfigDir() + if err != nil { + return nil, err + } + saltFile := filepath.Join(configDir, "auth_salt") + var salt []byte + if b, err := os.ReadFile(saltFile); err == nil { + salt = b + } else { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("read salt file: %w", err) + } + s, err := crypto.GenerateKey() + if err != nil { + return nil, fmt.Errorf("generate salt: %w", err) + } + salt = s + if err := paths.SaveAtomic(saltFile, salt); err != nil { + return nil, fmt.Errorf("create salt file: %w", err) + } + } + + userID, err := currentUserID() + if err != nil { + return nil, fmt.Errorf("determine user identity: %w", err) + } + info := fmt.Sprintf("config-token-v1-%s-%s", paths.MachineID(), userID) + return crypto.DeriveKey(salt, info) +} + +func (b *ConfigTokenBackend) resolvePath(ref Ref) (string, error) { + p := strings.TrimPrefix(ref.Path, "/") + if p == "" || pathpkg.IsAbs(p) { + return "", errorf(KindInvalidRef, ref.Raw, "invalid managed token path; expected /", nil) + } + cleaned := pathpkg.Clean(p) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return "", errorf(KindInvalidRef, ref.Raw, "invalid managed token path; expected /", nil) + } + parts := strings.Split(cleaned, "/") + if len(parts) != 2 { + return "", errorf(KindInvalidRef, ref.Raw, "invalid managed token path; expected /", nil) + } + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return "", errorf(KindInvalidRef, ref.Raw, "invalid managed token path; expected /", nil) + } + } + + configDir, err := paths.ConfigDir() + if err != nil { + return "", errorf(KindBackendUnavailable, ref.Raw, "failed to determine config directory", err) + } + + // We store tokens in a 'tokens' subdirectory for hygiene. + tokenDir := filepath.Join(configDir, "tokens") + if err := os.MkdirAll(tokenDir, 0700); err != nil { + return "", errorf(KindBackendUnavailable, ref.Raw, "failed to create tokens directory", err) + } + + resolved := filepath.Join(tokenDir, filepath.FromSlash(cleaned)+".json") + rel, err := filepath.Rel(tokenDir, resolved) + if err != nil { + return "", errorf(KindBackendUnavailable, ref.Raw, "failed to resolve managed token path", err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return "", errorf(KindInvalidRef, ref.Raw, "managed token path escapes token directory", nil) + } + return resolved, nil +} + +func currentUserID() (string, error) { + u, err := user.Current() + if err != nil { + return "", err + } + if u.Uid != "" { + return u.Uid, nil + } + if u.Username != "" { + return u.Username, nil + } + return "", fmt.Errorf("current user has no stable identifier") +} diff --git a/internal/secretref/file_backend_test.go b/internal/secretref/file_backend_test.go new file mode 100644 index 0000000..fe47745 --- /dev/null +++ b/internal/secretref/file_backend_test.go @@ -0,0 +1,243 @@ +package secretref + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestFileBackend_BlobRoundTrip(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "secret.bin") + ref, _ := Parse("file://" + path) + data := []byte("top secret blob") + + backend := NewFileBackend() + ctx := context.Background() + + // 1. Save + if err := backend.SaveBlob(ctx, ref, data); err != nil { + t.Fatalf("SaveBlob failed: %v", err) + } + + // 2. Verify permissions (0600) - skip on Windows + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat failed: %v", err) + } + if info.Mode().Perm() != 0600 { + t.Errorf("expected permissions 0600, got %v", info.Mode().Perm()) + } + } + + // 3. Load + got, err := backend.LoadBlob(ctx, ref) + if err != nil { + t.Fatalf("LoadBlob failed: %v", err) + } + if string(got) != string(data) { + t.Errorf("expected %q, got %q", string(data), string(got)) + } + + // 4. Resolve (string) + val, err := backend.Resolve(ctx, ref) + if err != nil { + t.Fatalf("Resolve failed: %v", err) + } + if val != string(data) { + t.Errorf("expected %q, got %q", string(data), val) + } + + // 5. Exists + exists, err := backend.Exists(ctx, ref) + if err != nil { + t.Fatalf("Exists failed: %v", err) + } + if !exists { + t.Errorf("expected file to exist") + } + + // 6. Store + if err := backend.Store(ctx, ref, "new value"); err != nil { + t.Fatalf("Store failed: %v", err) + } + val, _ = backend.Resolve(ctx, ref) + if val != "new value" { + t.Errorf("expected 'new value', got %q", val) + } + + // 7. Delete + if err := backend.DeleteBlob(ctx, ref); err != nil { + t.Fatalf("DeleteBlob failed: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("file still exists after DeleteBlob") + } + + // 8. Not found + _, err = backend.LoadBlob(ctx, ref) + if err == nil { + t.Errorf("expected error for non-existent file") + } +} + +func TestFileBackend_Metadata(t *testing.T) { + backend := NewFileBackend() + if backend.Scheme() != "file" { + t.Errorf("expected scheme 'file', got %q", backend.Scheme()) + } + if !strings.Contains(backend.DisplayName(), "file") { + t.Errorf("expected display name to contain 'file', got %q", backend.DisplayName()) + } + if !backend.WriteSupported() { + t.Errorf("expected WriteSupported to be true") + } + def := backend.DefaultRef("test", "acc") + if !strings.HasPrefix(def, "file://") { + t.Errorf("expected default ref to start with 'file://', got %q", def) + } +} + +func TestConfigTokenBackend_BlobRoundTrip(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CLOUDSTIC_CONFIG_DIR", tmpDir) + + backend := NewConfigTokenBackend() + ctx := context.Background() + ref, _ := Parse("config-token://google/test-token") + data := []byte("{\"token\":\"fake\"}") + + // 1. Save + if err := backend.SaveBlob(ctx, ref, data); err != nil { + t.Fatalf("SaveBlob failed: %v", err) + } + + // 2. Verify it's in the right place + expectedPath := filepath.Join(tmpDir, "tokens", "google/test-token.json") + if _, err := os.Stat(expectedPath); err != nil { + t.Fatalf("expected file at %s: %v", expectedPath, err) + } + + // 3. Load + got, err := backend.LoadBlob(ctx, ref) + if err != nil { + t.Fatalf("LoadBlob failed: %v", err) + } + if string(got) != string(data) { + t.Errorf("expected %q, got %q", string(data), string(got)) + } + + // 4. Resolve + val, err := backend.Resolve(ctx, ref) + if err != nil { + t.Fatalf("Resolve failed: %v", err) + } + if val != string(data) { + t.Errorf("expected %q, got %q", string(data), val) + } + + // 5. Exists + exists, err := backend.Exists(ctx, ref) + if err != nil { + t.Fatalf("Exists failed: %v", err) + } + if !exists { + t.Errorf("expected token to exist") + } + + // 6. Delete + if err := backend.DeleteBlob(ctx, ref); err != nil { + t.Fatalf("DeleteBlob failed: %v", err) + } + exists, _ = backend.Exists(ctx, ref) + if exists { + t.Errorf("expected token to be deleted") + } + + // 7. Store + if err := backend.Store(ctx, ref, "test-store"); err != nil { + t.Fatalf("Store failed: %v", err) + } + val, _ = backend.Resolve(ctx, ref) + if val != "test-store" { + t.Errorf("expected 'test-store', got %q", val) + } +} + +func TestConfigTokenBackend_Metadata(t *testing.T) { + backend := NewConfigTokenBackend() + if backend.Scheme() != "config-token" { + t.Errorf("expected scheme 'config-token', got %q", backend.Scheme()) + } + if !backend.WriteSupported() { + t.Errorf("expected WriteSupported to be true") + } + def := backend.DefaultRef("myname", "myprovider") + if def != "config-token://myprovider/myname" { + t.Errorf("unexpected default ref: %q", def) + } + // Default provider + def2 := backend.DefaultRef("myname", "") + if def2 != "config-token://google/myname" { + t.Errorf("unexpected default ref for empty account: %q", def2) + } +} + +func TestConfigTokenBackend_DecryptionFallback(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CLOUDSTIC_CONFIG_DIR", tmpDir) + + backend := NewConfigTokenBackend() + ctx := context.Background() + ref, _ := Parse("config-token://google/legacy") + + // Manually write unencrypted data + tokenDir := filepath.Join(tmpDir, "tokens", "google") + if err := os.MkdirAll(tokenDir, 0700); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + rawPath := filepath.Join(tokenDir, "legacy.json") + data := []byte("plain text token") + if err := os.WriteFile(rawPath, data, 0600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + // Load should fall back to plaintext + got, err := backend.LoadBlob(ctx, ref) + if err != nil { + t.Fatalf("LoadBlob failed: %v", err) + } + if string(got) != string(data) { + t.Errorf("expected %q, got %q", string(data), string(got)) + } +} + +func TestConfigTokenBackend_InvalidRef(t *testing.T) { + backend := NewConfigTokenBackend() + ctx := context.Background() + + ref, _ := Parse("config-token://") // Parse won't actually allow this but let's be sure + ref.Path = "" + + _, err := backend.LoadBlob(ctx, ref) + if err == nil { + t.Errorf("expected error for empty path") + } +} + +func TestConfigTokenBackend_RejectsPathTraversal(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CLOUDSTIC_CONFIG_DIR", tmpDir) + + backend := NewConfigTokenBackend() + ctx := context.Background() + ref, _ := Parse("config-token://google/../escape") + + if err := backend.SaveBlob(ctx, ref, []byte("secret")); err == nil { + t.Fatal("expected traversal ref to fail") + } +} diff --git a/internal/secretref/keychain_backend.go b/internal/secretref/keychain_backend.go index 2be9504..6d8905f 100644 --- a/internal/secretref/keychain_backend.go +++ b/internal/secretref/keychain_backend.go @@ -12,41 +12,47 @@ var ( errKeychainUnavailable = errors.New("keychain backend unavailable") ) -type keychainLookupFunc func(ctx context.Context, service, account string) (string, error) +type keychainLookupBlobFunc func(ctx context.Context, service, account string) ([]byte, error) type keychainExistsFunc func(ctx context.Context, service, account string) (bool, error) -type keychainStoreFunc func(ctx context.Context, service, account, value string) error +type keychainStoreBlobFunc func(ctx context.Context, service, account string, value []byte) error +type keychainDeleteFunc func(ctx context.Context, service, account string) error // KeychainBackend resolves keychain://service/account references. type KeychainBackend struct { - lookup keychainLookupFunc - exists keychainExistsFunc - store keychainStoreFunc + lookupBlob keychainLookupBlobFunc + exists keychainExistsFunc + storeBlob keychainStoreBlobFunc + delete keychainDeleteFunc } // NewKeychainBackend creates a keychain backend for the current platform. func NewKeychainBackend() *KeychainBackend { return &KeychainBackend{ - lookup: defaultKeychainLookup, - exists: defaultKeychainExists, - store: defaultKeychainStore, + lookupBlob: defaultKeychainLookupBlob, + exists: defaultKeychainExists, + storeBlob: defaultKeychainStoreBlob, + delete: defaultKeychainDelete, } } -func newKeychainBackendWithFns(lookup keychainLookupFunc, exists keychainExistsFunc, store keychainStoreFunc) *KeychainBackend { +func newKeychainBackendWithFns(lookup keychainLookupBlobFunc, exists keychainExistsFunc, store keychainStoreBlobFunc, deleteFn keychainDeleteFunc) *KeychainBackend { if lookup == nil { - lookup = defaultKeychainLookup + lookup = defaultKeychainLookupBlob } if exists == nil { exists = defaultKeychainExists } if store == nil { - store = defaultKeychainStore + store = defaultKeychainStoreBlob } - return &KeychainBackend{lookup: lookup, exists: exists, store: store} + if deleteFn == nil { + deleteFn = defaultKeychainDelete + } + return &KeychainBackend{lookupBlob: lookup, exists: exists, storeBlob: store, delete: deleteFn} } -func newKeychainBackendWithLookup(lookup keychainLookupFunc) *KeychainBackend { - return newKeychainBackendWithFns(lookup, nil, nil) +func newKeychainBackendWithLookup(lookup keychainLookupBlobFunc) *KeychainBackend { + return newKeychainBackendWithFns(lookup, nil, nil, nil) } func parseKeychainPath(path string) (service string, account string, err error) { @@ -68,20 +74,28 @@ func parseKeychainPath(path string) (service string, account string, err error) } func (b *KeychainBackend) Resolve(ctx context.Context, ref Ref) (string, error) { + data, err := b.LoadBlob(ctx, ref) + if err != nil { + return "", err + } + return strings.TrimRight(string(data), "\r\n"), nil +} + +func (b *KeychainBackend) LoadBlob(ctx context.Context, ref Ref) ([]byte, error) { service, account, err := parseKeychainPath(ref.Path) if err != nil { - return "", errorf(KindInvalidRef, ref.Raw, err.Error(), nil) + return nil, errorf(KindInvalidRef, ref.Raw, err.Error(), nil) } - value, err := b.lookup(ctx, service, account) + value, err := b.lookupBlob(ctx, service, account) if err != nil { switch { case errors.Is(err, errKeychainNotFound): - return "", errorf(KindNotFound, ref.Raw, fmt.Sprintf("keychain item %q/%q not found", service, account), err) + return nil, errorf(KindNotFound, ref.Raw, fmt.Sprintf("keychain item %q/%q not found", service, account), err) case errors.Is(err, errKeychainUnavailable): - return "", errorf(KindBackendUnavailable, ref.Raw, err.Error(), err) + return nil, errorf(KindBackendUnavailable, ref.Raw, err.Error(), err) default: - return "", errorf(KindBackendUnavailable, ref.Raw, err.Error(), err) + return nil, errorf(KindBackendUnavailable, ref.Raw, err.Error(), err) } } @@ -118,12 +132,33 @@ func (b *KeychainBackend) Exists(ctx context.Context, ref Ref) (bool, error) { } func (b *KeychainBackend) Store(ctx context.Context, ref Ref, value string) error { + return b.SaveBlob(ctx, ref, []byte(value)) +} + +func (b *KeychainBackend) SaveBlob(ctx context.Context, ref Ref, data []byte) error { + service, account, err := parseKeychainPath(ref.Path) + if err != nil { + return errorf(KindInvalidRef, ref.Raw, err.Error(), nil) + } + + if err := b.storeBlob(ctx, service, account, data); err != nil { + switch { + case errors.Is(err, errKeychainUnavailable): + return errorf(KindBackendUnavailable, ref.Raw, err.Error(), err) + default: + return errorf(KindBackendUnavailable, ref.Raw, err.Error(), err) + } + } + return nil +} + +func (b *KeychainBackend) DeleteBlob(ctx context.Context, ref Ref) error { service, account, err := parseKeychainPath(ref.Path) if err != nil { return errorf(KindInvalidRef, ref.Raw, err.Error(), nil) } - if err := b.store(ctx, service, account, value); err != nil { + if err := b.delete(ctx, service, account); err != nil { switch { case errors.Is(err, errKeychainUnavailable): return errorf(KindBackendUnavailable, ref.Raw, err.Error(), err) diff --git a/internal/secretref/keychain_backend_darwin.go b/internal/secretref/keychain_backend_darwin.go index d1fba78..6bb40c5 100644 --- a/internal/secretref/keychain_backend_darwin.go +++ b/internal/secretref/keychain_backend_darwin.go @@ -6,7 +6,6 @@ import ( "context" "errors" "fmt" - "strings" "github.com/keybase/go-keychain" ) @@ -14,31 +13,32 @@ import ( var keychainGetGenericPasswordDarwin = keychain.GetGenericPassword var keychainAddItemDarwin = keychain.AddItem var keychainUpdateItemDarwin = keychain.UpdateItem +var keychainDeleteItemDarwin = keychain.DeleteItem func defaultKeychainWriteSupported() bool { return true } -func defaultKeychainLookup(ctx context.Context, service, account string) (string, error) { +func defaultKeychainLookupBlob(ctx context.Context, service, account string) ([]byte, error) { _ = ctx out, err := keychainGetGenericPasswordDarwin(service, account, "", "") if err != nil { switch err { case keychain.ErrorItemNotFound: - return "", errKeychainNotFound + return nil, errKeychainNotFound case keychain.ErrorInteractionNotAllowed, keychain.ErrorNotAvailable, keychain.ErrorNoSuchKeychain: - return "", fmt.Errorf("%w: keychain locked or unavailable in this session", errKeychainUnavailable) + return nil, fmt.Errorf("%w: keychain locked or unavailable in this session", errKeychainUnavailable) default: - return "", fmt.Errorf("keychain lookup failed: %w", err) + return nil, fmt.Errorf("keychain lookup failed: %w", err) } } if out == nil { - return "", errKeychainNotFound + return nil, errKeychainNotFound } - return strings.TrimRight(string(out), "\r\n"), nil + return out, nil } func defaultKeychainExists(ctx context.Context, service, account string) (bool, error) { - _, err := defaultKeychainLookup(ctx, service, account) + _, err := defaultKeychainLookupBlob(ctx, service, account) if err != nil { switch { case errors.Is(err, errKeychainNotFound): @@ -50,13 +50,13 @@ func defaultKeychainExists(ctx context.Context, service, account string) (bool, return true, nil } -func defaultKeychainStore(_ context.Context, service, account, value string) error { +func defaultKeychainStoreBlob(_ context.Context, service, account string, value []byte) error { item := keychain.NewItem() item.SetSecClass(keychain.SecClassGenericPassword) item.SetService(service) item.SetAccount(account) item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly) - item.SetData([]byte(value)) + item.SetData(value) if err := keychainAddItemDarwin(item); err != nil { if err == keychain.ErrorDuplicateItem { @@ -66,7 +66,7 @@ func defaultKeychainStore(_ context.Context, service, account, value string) err query.SetAccount(account) update := keychain.NewItem() - update.SetData([]byte(value)) + update.SetData(value) if updateErr := keychainUpdateItemDarwin(query, update); updateErr != nil { return mapKeychainStoreError(updateErr) @@ -78,6 +78,21 @@ func defaultKeychainStore(_ context.Context, service, account, value string) err return nil } +func defaultKeychainDelete(_ context.Context, service, account string) error { + query := keychain.NewItem() + query.SetSecClass(keychain.SecClassGenericPassword) + query.SetService(service) + query.SetAccount(account) + + if err := keychainDeleteItemDarwin(query); err != nil { + if err == keychain.ErrorItemNotFound { + return nil + } + return mapKeychainStoreError(err) + } + return nil +} + func mapKeychainStoreError(err error) error { switch err { case keychain.ErrorInteractionNotAllowed, keychain.ErrorNotAvailable, keychain.ErrorNoSuchKeychain: diff --git a/internal/secretref/keychain_backend_darwin_test.go b/internal/secretref/keychain_backend_darwin_test.go index 007278b..2f10050 100644 --- a/internal/secretref/keychain_backend_darwin_test.go +++ b/internal/secretref/keychain_backend_darwin_test.go @@ -18,7 +18,7 @@ func TestDefaultKeychainLookup_NotFoundOnNilData(t *testing.T) { return nil, nil } - _, err := defaultKeychainLookup(context.Background(), "svc", "acct") + _, err := defaultKeychainLookupBlob(context.Background(), "svc", "acct") if !errors.Is(err, errKeychainNotFound) { t.Fatalf("expected errKeychainNotFound, got %v", err) } @@ -32,7 +32,7 @@ func TestDefaultKeychainLookup_MapsInteractionNotAllowed(t *testing.T) { return nil, keychain.ErrorInteractionNotAllowed } - _, err := defaultKeychainLookup(context.Background(), "svc", "acct") + _, err := defaultKeychainLookupBlob(context.Background(), "svc", "acct") if !errors.Is(err, errKeychainUnavailable) { t.Fatalf("expected errKeychainUnavailable, got %v", err) } @@ -55,8 +55,8 @@ func TestDefaultKeychainStore_DuplicateUpdates(t *testing.T) { return nil } - if err := defaultKeychainStore(context.Background(), "svc", "acct", "secret"); err != nil { - t.Fatalf("defaultKeychainStore: %v", err) + if err := defaultKeychainStoreBlob(context.Background(), "svc", "acct", []byte("secret")); err != nil { + t.Fatalf("defaultKeychainStoreBlob: %v", err) } if !updated { t.Fatal("expected update on duplicate item") diff --git a/internal/secretref/keychain_backend_stub.go b/internal/secretref/keychain_backend_stub.go index 40daf9b..92751f3 100644 --- a/internal/secretref/keychain_backend_stub.go +++ b/internal/secretref/keychain_backend_stub.go @@ -9,14 +9,18 @@ import ( func defaultKeychainWriteSupported() bool { return false } -func defaultKeychainLookup(_ context.Context, _, _ string) (string, error) { - return "", fmt.Errorf("%w: keychain backend is only available on macOS", errKeychainUnavailable) +func defaultKeychainLookupBlob(_ context.Context, _, _ string) ([]byte, error) { + return nil, fmt.Errorf("%w: keychain backend is only available on macOS", errKeychainUnavailable) } func defaultKeychainExists(_ context.Context, _, _ string) (bool, error) { return false, fmt.Errorf("%w: keychain backend is only available on macOS", errKeychainUnavailable) } -func defaultKeychainStore(_ context.Context, _, _, _ string) error { +func defaultKeychainStoreBlob(_ context.Context, _, _ string, _ []byte) error { + return fmt.Errorf("%w: keychain backend is only available on macOS", errKeychainUnavailable) +} + +func defaultKeychainDelete(_ context.Context, _, _ string) error { return fmt.Errorf("%w: keychain backend is only available on macOS", errKeychainUnavailable) } diff --git a/internal/secretref/keychain_backend_test.go b/internal/secretref/keychain_backend_test.go index 313d6ec..25fb27e 100644 --- a/internal/secretref/keychain_backend_test.go +++ b/internal/secretref/keychain_backend_test.go @@ -40,11 +40,11 @@ func TestParseKeychainPath(t *testing.T) { } func TestKeychainBackend_Resolve(t *testing.T) { - b := newKeychainBackendWithLookup(func(_ context.Context, service, account string) (string, error) { + b := newKeychainBackendWithLookup(func(_ context.Context, service, account string) ([]byte, error) { if service != "cloudstic/prod" || account != "password" { t.Fatalf("unexpected lookup args: %q/%q", service, account) } - return "s3cr3t", nil + return []byte("s3cr3t"), nil }) got, err := b.Resolve(context.Background(), Ref{Raw: "keychain://cloudstic/prod/password", Scheme: "keychain", Path: "cloudstic/prod/password"}) @@ -84,11 +84,11 @@ func TestKeychainBackend_ResolveErrors(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - b := newKeychainBackendWithLookup(func(context.Context, string, string) (string, error) { + b := newKeychainBackendWithLookup(func(context.Context, string, string) ([]byte, error) { if tc.err == nil { - return "", nil + return nil, nil } - return "", tc.err + return nil, tc.err }) _, err := b.Resolve(context.Background(), tc.ref) @@ -115,7 +115,7 @@ func TestKeychainBackend_DefaultRef(t *testing.T) { func TestKeychainBackend_Exists(t *testing.T) { b := newKeychainBackendWithFns( - func(context.Context, string, string) (string, error) { return "", nil }, + func(context.Context, string, string) ([]byte, error) { return nil, nil }, func(_ context.Context, service, account string) (bool, error) { if service != "cloudstic/store/prod" || account != "password" { t.Fatalf("unexpected args %q/%q", service, account) @@ -123,6 +123,7 @@ func TestKeychainBackend_Exists(t *testing.T) { return true, nil }, nil, + nil, ) exists, err := b.Exists(context.Background(), Ref{Raw: "keychain://cloudstic/store/prod/password", Scheme: "keychain", Path: "cloudstic/store/prod/password"}) if err != nil { @@ -135,16 +136,54 @@ func TestKeychainBackend_Exists(t *testing.T) { func TestKeychainBackend_Store(t *testing.T) { b := newKeychainBackendWithFns( - func(context.Context, string, string) (string, error) { return "", nil }, + func(context.Context, string, string) ([]byte, error) { return nil, nil }, nil, - func(_ context.Context, service, account, value string) error { - if service != "cloudstic/store/prod" || account != "password" || value != "secret" { - t.Fatalf("unexpected args %q/%q/%q", service, account, value) + func(_ context.Context, service, account string, value []byte) error { + if service != "cloudstic/store/prod" || account != "password" || string(value) != "secret" { + t.Fatalf("unexpected args %q/%q/%q", service, account, string(value)) } return nil }, + nil, ) if err := b.Store(context.Background(), Ref{Raw: "keychain://cloudstic/store/prod/password", Scheme: "keychain", Path: "cloudstic/store/prod/password"}, "secret"); err != nil { t.Fatalf("Store: %v", err) } } + +func TestKeychainBackend_BlobRoundTrip(t *testing.T) { + var storedValue []byte + b := newKeychainBackendWithFns( + func(context.Context, string, string) ([]byte, error) { return storedValue, nil }, + nil, + func(_ context.Context, service, account string, value []byte) error { + storedValue = value + return nil + }, + func(_ context.Context, service, account string) error { + storedValue = nil + return nil + }, + ) + + ctx := context.Background() + ref := Ref{Raw: "keychain://cloudstic/token/gdrive", Scheme: "keychain", Path: "cloudstic/token/gdrive"} + data := []byte("binary-data") + + if err := b.SaveBlob(ctx, ref, data); err != nil { + t.Fatalf("SaveBlob: %v", err) + } + got, err := b.LoadBlob(ctx, ref) + if err != nil { + t.Fatalf("LoadBlob: %v", err) + } + if string(got) != string(data) { + t.Errorf("got %q want %q", string(got), string(data)) + } + if err := b.DeleteBlob(ctx, ref); err != nil { + t.Fatalf("DeleteBlob: %v", err) + } + if storedValue != nil { + t.Fatal("expected storedValue to be nil after delete") + } +} diff --git a/internal/secretref/secretref.go b/internal/secretref/secretref.go index 9d9abf4..f7f7525 100644 --- a/internal/secretref/secretref.go +++ b/internal/secretref/secretref.go @@ -86,6 +86,20 @@ type Backend interface { Resolve(ctx context.Context, ref Ref) (string, error) } +// BlobBackend extends a backend to support binary data retrieval. +type BlobBackend interface { + Backend + Scheme() string + LoadBlob(ctx context.Context, ref Ref) ([]byte, error) +} + +// WritableBlobBackend extends a backend to support atomic binary data storage. +type WritableBlobBackend interface { + BlobBackend + SaveBlob(ctx context.Context, ref Ref, data []byte) error + DeleteBlob(ctx context.Context, ref Ref) error +} + // WritableBackend extends a backend with native-store write and existence checks // for interactive CLI flows. type WritableBackend interface { @@ -112,10 +126,13 @@ func NewResolver(backends map[string]Backend) *Resolver { return r } -// NewDefaultResolver builds the baseline resolver with env:// and keychain:// support. +// NewDefaultResolver builds the standard resolver with built-in env, file, +// config-token, and platform-native secret backends. func NewDefaultResolver() *Resolver { return NewResolver(map[string]Backend{ "env": NewEnvBackend(nil), + "file": NewFileBackend(), + "config-token": NewConfigTokenBackend(), "keychain": NewKeychainBackend(), "wincred": NewWincredBackend(), "secret-service": NewSecretServiceBackend(), @@ -140,6 +157,73 @@ func (r *Resolver) Resolve(ctx context.Context, raw string) (string, error) { return value, nil } +// LoadBlob parses and retrieves a binary blob from a secret reference. +func (r *Resolver) LoadBlob(ctx context.Context, raw string) ([]byte, error) { + parsed, backend, err := r.lookupBackend(raw) + if err != nil { + return nil, err + } + + blobBackend, ok := backend.(BlobBackend) + if !ok { + return nil, errorf(KindBackendUnavailable, parsed.Raw, fmt.Sprintf("scheme %q does not support loading blobs", parsed.Scheme), nil) + } + + data, err := blobBackend.LoadBlob(ctx, parsed) + if err != nil { + var refErr *Error + if errors.As(err, &refErr) { + return nil, err + } + return nil, errorf(KindBackendUnavailable, parsed.Raw, err.Error(), err) + } + return data, nil +} + +// SaveBlob parses and atomically stores a binary blob to a secret reference. +func (r *Resolver) SaveBlob(ctx context.Context, raw string, data []byte) error { + parsed, backend, err := r.lookupBackend(raw) + if err != nil { + return err + } + + writable, ok := backend.(WritableBlobBackend) + if !ok { + return errorf(KindBackendUnavailable, parsed.Raw, fmt.Sprintf("scheme %q does not support saving blobs", parsed.Scheme), nil) + } + + if err := writable.SaveBlob(ctx, parsed, data); err != nil { + var refErr *Error + if errors.As(err, &refErr) { + return err + } + return errorf(KindBackendUnavailable, parsed.Raw, err.Error(), err) + } + return nil +} + +// DeleteBlob parses and removes a binary blob from a secret reference. +func (r *Resolver) DeleteBlob(ctx context.Context, raw string) error { + parsed, backend, err := r.lookupBackend(raw) + if err != nil { + return err + } + + writable, ok := backend.(WritableBlobBackend) + if !ok { + return errorf(KindBackendUnavailable, parsed.Raw, fmt.Sprintf("scheme %q does not support deleting blobs", parsed.Scheme), nil) + } + + if err := writable.DeleteBlob(ctx, parsed); err != nil { + var refErr *Error + if errors.As(err, &refErr) { + return err + } + return errorf(KindBackendUnavailable, parsed.Raw, err.Error(), err) + } + return nil +} + // Exists reports whether a writable secret reference already exists. func (r *Resolver) Exists(ctx context.Context, raw string) (bool, error) { parsed, writable, err := r.lookupWritableBackend(raw) diff --git a/pkg/source/gdrive.go b/pkg/source/gdrive.go index a28c523..4d3cb9f 100644 --- a/pkg/source/gdrive.go +++ b/pkg/source/gdrive.go @@ -7,12 +7,13 @@ import ( "io" "net/http" "os" - "path/filepath" "strings" "time" "github.com/cloudstic/cli/internal/core" + "github.com/cloudstic/cli/internal/paths" "github.com/cloudstic/cli/internal/retry" + "github.com/cloudstic/cli/internal/secretref" "golang.org/x/oauth2" "golang.org/x/oauth2/google" @@ -24,8 +25,11 @@ import ( // gDriveOptions holds configuration for a Google Drive source. type gDriveOptions struct { httpClient *http.Client + resolver *secretref.Resolver credsPath string + credsRef string tokenPath string + tokenRef string driveID string driveName string rootFolderID string @@ -45,6 +49,13 @@ func WithHTTPClient(client *http.Client) GDriveOption { } } +// WithResolver sets the secret resolver for ref-based auth. +func WithResolver(r *secretref.Resolver) GDriveOption { + return func(o *gDriveOptions) { + o.resolver = r + } +} + // WithCredsPath sets the path to the credentials JSON file. // If empty, uses the built-in OAuth client. func WithCredsPath(path string) GDriveOption { @@ -53,6 +64,13 @@ func WithCredsPath(path string) GDriveOption { } } +// WithCredsRef sets the secret reference to the credentials JSON. +func WithCredsRef(ref string) GDriveOption { + return func(o *gDriveOptions) { + o.credsRef = ref + } +} + // WithTokenPath sets the path where the OAuth token is cached. func WithTokenPath(path string) GDriveOption { return func(o *gDriveOptions) { @@ -60,6 +78,13 @@ func WithTokenPath(path string) GDriveOption { } } +// WithTokenRef sets the secret reference where the OAuth token is cached. +func WithTokenRef(ref string) GDriveOption { + return func(o *gDriveOptions) { + o.tokenRef = ref + } +} + // WithDriveName sets the shared drive name to use. It will be resolved to a Drive ID. func WithDriveName(name string) GDriveOption { return func(o *gDriveOptions) { @@ -144,14 +169,23 @@ func NewGDriveSource(ctx context.Context, opts ...GDriveOption) (*GDriveSource, if err != nil { return nil, fmt.Errorf("create drive client (custom http client): %w", err) } - } else if cfg.credsPath != "" { - b, err := os.ReadFile(cfg.credsPath) - if err != nil { - return nil, fmt.Errorf("read credentials file: %w", err) + } else if cfg.credsRef != "" || cfg.credsPath != "" { + var b []byte + if cfg.credsRef != "" && cfg.resolver != nil { + b, err = cfg.resolver.LoadBlob(ctx, cfg.credsRef) + if err != nil { + return nil, fmt.Errorf("load credentials from ref %q: %w", cfg.credsRef, err) + } + } else if cfg.credsPath != "" { + b, err = os.ReadFile(cfg.credsPath) + if err != nil { + return nil, fmt.Errorf("read credentials file: %w", err) + } } + config, err := google.ConfigFromJSON(b, drive.DriveReadonlyScope) if err == nil { - client, err := oauthClient(config, cfg.tokenPath) + client, err := oauthClient(ctx, config, cfg.resolver, cfg.tokenRef, cfg.tokenPath) if err != nil { return nil, err } @@ -160,9 +194,14 @@ func NewGDriveSource(ctx context.Context, opts ...GDriveOption) (*GDriveSource, return nil, fmt.Errorf("create drive client (user auth): %w", err) } } else { - srv, err = drive.NewService(ctx, option.WithCredentialsFile(cfg.credsPath)) + // Try service account if it wasn't a user config + if cfg.credsPath != "" { + srv, err = drive.NewService(ctx, option.WithCredentialsFile(cfg.credsPath)) + } else { + srv, err = drive.NewService(ctx, option.WithCredentialsJSON(b)) + } if err != nil { - return nil, fmt.Errorf("create drive client: %w", err) + return nil, fmt.Errorf("create drive client (service account): %w", err) } } } else { @@ -172,7 +211,7 @@ func NewGDriveSource(ctx context.Context, opts ...GDriveOption) (*GDriveSource, Scopes: []string{drive.DriveReadonlyScope}, Endpoint: google.Endpoint, } - client, err := oauthClient(config, cfg.tokenPath) + client, err := oauthClient(ctx, config, cfg.resolver, cfg.tokenRef, cfg.tokenPath) if err != nil { return nil, err } @@ -333,18 +372,54 @@ func (s *GDriveSource) resolvePathToFolderID(ctx context.Context, path string) ( // OAuth helpers // --------------------------------------------------------------------------- -func oauthClient(config *oauth2.Config, tokFile string) (*http.Client, error) { - tok, err := tokenFromFile(tokFile) +func oauthClient(ctx context.Context, config *oauth2.Config, r *secretref.Resolver, tokRef, tokFile string) (*http.Client, error) { + var tok *oauth2.Token + var err error + if tokRef != "" && r == nil { + return nil, fmt.Errorf("token ref %q requires a resolver", tokRef) + } + + if tokRef != "" { + tok, err = tokenFromRef(ctx, r, tokRef) + } else if tokFile != "" { + tok, err = tokenFromFile(tokFile) + } else { + err = fmt.Errorf("no token storage configured") + } + if err != nil { tok, err = tokenFromWeb(config) if err != nil { return nil, err } - if err := saveToken(tokFile, tok); err != nil { - return nil, err + if tokRef != "" { + if err := saveTokenRef(ctx, r, tokRef, tok); err != nil { + return nil, err + } + } else if tokFile != "" { + if err := saveToken(tokFile, tok); err != nil { + return nil, err + } } } - return config.Client(context.Background(), tok), nil + + // Use a persistent token source so that refreshes are saved. + ts := oauth2.ReuseTokenSource(tok, config.TokenSource(ctx, tok)) + pts := &persistentTokenSource{ + ts: ts, + lastTok: tok, + save: func(t *oauth2.Token) error { + if tokRef != "" { + return saveTokenRef(ctx, r, tokRef, t) + } + if tokFile != "" { + return saveToken(tokFile, t) + } + return nil + }, + } + + return oauth2.NewClient(ctx, pts), nil } func tokenFromWeb(config *oauth2.Config) (*oauth2.Token, error) { @@ -362,16 +437,32 @@ func tokenFromFile(file string) (*oauth2.Token, error) { return tok, err } +func tokenFromRef(ctx context.Context, r *secretref.Resolver, ref string) (*oauth2.Token, error) { + data, err := r.LoadBlob(ctx, ref) + if err != nil { + return nil, err + } + tok := &oauth2.Token{} + if err := json.Unmarshal(data, tok); err != nil { + return nil, fmt.Errorf("decode token from ref: %w", err) + } + return tok, nil +} + func saveToken(path string, token *oauth2.Token) error { - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return fmt.Errorf("create token directory: %w", err) + data, err := json.Marshal(token) + if err != nil { + return err } - f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + return paths.SaveAtomic(path, data) +} + +func saveTokenRef(ctx context.Context, r *secretref.Resolver, ref string, token *oauth2.Token) error { + data, err := json.Marshal(token) if err != nil { - return fmt.Errorf("create token file: %w", err) + return err } - defer func() { _ = f.Close() }() - return json.NewEncoder(f).Encode(token) + return r.SaveBlob(ctx, ref, data) } // driveCallWithRetry wraps a Google API call with retry logic for transient errors. diff --git a/pkg/source/gdrive_auth_test.go b/pkg/source/gdrive_auth_test.go new file mode 100644 index 0000000..e9f48e3 --- /dev/null +++ b/pkg/source/gdrive_auth_test.go @@ -0,0 +1,95 @@ +package source + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/cloudstic/cli/internal/secretref" + "golang.org/x/oauth2" +) + +func TestGDrive_TokenPersistence(t *testing.T) { + tmpDir := t.TempDir() + tokPath := filepath.Join(tmpDir, "token.json") + tok := &oauth2.Token{AccessToken: "secret-token", RefreshToken: "refresh"} + + // 1. Save and Load from file + if err := saveToken(tokPath, tok); err != nil { + t.Fatalf("saveToken failed: %v", err) + } + got, err := tokenFromFile(tokPath) + if err != nil { + t.Fatalf("tokenFromFile failed: %v", err) + } + if got.AccessToken != tok.AccessToken { + t.Errorf("got %q, want %q", got.AccessToken, tok.AccessToken) + } + + // 2. Save and Load from ref + resolver := secretref.NewDefaultResolver() + refPath := filepath.Join(tmpDir, "ref-token.json") + ref := "file://" + refPath + ctx := context.Background() + + if err := saveTokenRef(ctx, resolver, ref, tok); err != nil { + t.Fatalf("saveTokenRef failed: %v", err) + } + gotRef, err := tokenFromRef(ctx, resolver, ref) + if err != nil { + t.Fatalf("tokenFromRef failed: %v", err) + } + if gotRef.AccessToken != tok.AccessToken { + t.Errorf("got %q, want %q", gotRef.AccessToken, tok.AccessToken) + } +} + +func TestGDrive_Options(t *testing.T) { + resolver := secretref.NewDefaultResolver() + opts := []GDriveOption{ + WithResolver(resolver), + WithCredsRef("keychain://creds"), + WithTokenRef("config-token://google/tok"), + WithCredsPath("/path/creds.json"), + WithTokenPath("/path/tok.json"), + WithDriveName("My Shared Drive"), + WithDriveID("drive-id"), + WithRootFolderID("root-id"), + WithRootPath("/backup/path"), + WithAccountEmail("user@example.com"), + WithGDriveExcludePatterns([]string{"node_modules"}), + } + + var cfg gDriveOptions + for _, opt := range opts { + opt(&cfg) + } + + if cfg.resolver != resolver { + t.Error("resolver not set") + } + if cfg.credsRef != "keychain://creds" { + t.Error("credsRef not set") + } + if cfg.tokenRef != "config-token://google/tok" { + t.Error("tokenRef not set") + } + if cfg.credsPath != "/path/creds.json" { + t.Error("credsPath not set") + } + if cfg.tokenPath != "/path/tok.json" { + t.Error("tokenPath not set") + } +} + +func TestOAuthClient_RequiresResolverForTokenRef(t *testing.T) { + config := &oauth2.Config{} + _, err := oauthClient(context.Background(), config, nil, "config-token://google/tok", "") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "requires a resolver") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/pkg/source/oauth_persistent.go b/pkg/source/oauth_persistent.go new file mode 100644 index 0000000..943e99a --- /dev/null +++ b/pkg/source/oauth_persistent.go @@ -0,0 +1,35 @@ +package source + +import ( + "sync" + + "github.com/cloudstic/cli/internal/logger" + "golang.org/x/oauth2" +) + +// persistentTokenSource wraps an oauth2.TokenSource and calls a save function +// whenever a new token is produced (e.g. after a refresh). +type persistentTokenSource struct { + ts oauth2.TokenSource + save func(*oauth2.Token) error + lastTok *oauth2.Token + mu sync.Mutex +} + +func (pts *persistentTokenSource) Token() (*oauth2.Token, error) { + tok, err := pts.ts.Token() + if err != nil { + return nil, err + } + pts.mu.Lock() + shouldSave := pts.lastTok == nil || tok.AccessToken != pts.lastTok.AccessToken + if shouldSave { + if err := pts.save(tok); err != nil { + // Log error but don't fail, as the token is still valid for this session. + logger.Debugf("failed to persist refreshed OAuth token: %v", err) + } + pts.lastTok = tok + } + pts.mu.Unlock() + return tok, nil +} diff --git a/pkg/source/oauth_persistent_test.go b/pkg/source/oauth_persistent_test.go new file mode 100644 index 0000000..16fe544 --- /dev/null +++ b/pkg/source/oauth_persistent_test.go @@ -0,0 +1,123 @@ +package source + +import ( + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "golang.org/x/oauth2" +) + +type mockTokenSource struct { + tok *oauth2.Token + err error +} + +func (m *mockTokenSource) Token() (*oauth2.Token, error) { + return m.tok, m.err +} + +func TestPersistentTokenSource(t *testing.T) { + t1 := &oauth2.Token{AccessToken: "token1", Expiry: time.Now().Add(time.Hour)} + t2 := &oauth2.Token{AccessToken: "token2", Expiry: time.Now().Add(2 * time.Hour)} + + mock := &mockTokenSource{tok: t1} + saveCount := 0 + var lastSaved *oauth2.Token + + pts := &persistentTokenSource{ + ts: mock, + lastTok: t1, + save: func(tok *oauth2.Token) error { + saveCount++ + lastSaved = tok + return nil + }, + } + + // 1. Same token should not trigger save + got, err := pts.Token() + if err != nil { + t.Fatal(err) + } + if got.AccessToken != "token1" { + t.Errorf("got %q, want %q", got.AccessToken, "token1") + } + if saveCount != 0 { + t.Errorf("expected 0 saves, got %d", saveCount) + } + + // 2. Different token should trigger save + mock.tok = t2 + got, err = pts.Token() + if err != nil { + t.Fatal(err) + } + if got.AccessToken != "token2" { + t.Errorf("got %q, want %q", got.AccessToken, "token2") + } + if saveCount != 1 { + t.Errorf("expected 1 save, got %d", saveCount) + } + if lastSaved.AccessToken != "token2" { + t.Errorf("saved %q, want %q", lastSaved.AccessToken, "token2") + } + + // 3. Error from underlying source + mock.err = errors.New("fail") + _, err = pts.Token() + if err == nil { + t.Error("expected error") + } + + // 4. Save error should not prevent returning token + mock.err = nil + mock.tok = &oauth2.Token{AccessToken: "token3"} + pts.save = func(tok *oauth2.Token) error { + return errors.New("save fail") + } + got, err = pts.Token() + if err != nil { + t.Fatal(err) + } + if got.AccessToken != "token3" { + t.Errorf("got %q, want %q", got.AccessToken, "token3") + } +} + +func TestPersistentTokenSource_ConcurrentCallsSaveOnce(t *testing.T) { + tok := &oauth2.Token{AccessToken: "token1", Expiry: time.Now().Add(time.Hour)} + mock := &mockTokenSource{tok: tok} + var saves atomic.Int32 + pts := &persistentTokenSource{ + ts: mock, + save: func(tok *oauth2.Token) error { + saves.Add(1) + time.Sleep(10 * time.Millisecond) + return nil + }, + } + + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + got, err := pts.Token() + if err != nil { + t.Errorf("Token failed: %v", err) + return + } + if got.AccessToken != "token1" { + t.Errorf("got %q want %q", got.AccessToken, "token1") + } + }() + } + wg.Wait() + + if got := saves.Load(); got != 1 { + t.Fatalf("expected 1 save, got %d", got) + } +} diff --git a/pkg/source/onedrive.go b/pkg/source/onedrive.go index e9fbffb..b72e9c7 100644 --- a/pkg/source/onedrive.go +++ b/pkg/source/onedrive.go @@ -8,12 +8,13 @@ import ( "net/http" "net/url" "os" - "path/filepath" "strings" "time" "github.com/cloudstic/cli/internal/core" + "github.com/cloudstic/cli/internal/paths" "github.com/cloudstic/cli/internal/retry" + "github.com/cloudstic/cli/internal/secretref" "golang.org/x/oauth2" "golang.org/x/oauth2/microsoft" @@ -22,7 +23,9 @@ import ( // oneDriveOptions holds configuration for a OneDrive source. type oneDriveOptions struct { clientID string + resolver *secretref.Resolver tokenPath string + tokenRef string driveName string rootPath string excludePatterns []string @@ -38,6 +41,13 @@ func WithOneDriveClientID(id string) OneDriveOption { } } +// WithOneDriveResolver sets the secret resolver for ref-based auth. +func WithOneDriveResolver(r *secretref.Resolver) OneDriveOption { + return func(o *oneDriveOptions) { + o.resolver = r + } +} + // WithOneDriveDriveName sets the shared drive name. func WithOneDriveDriveName(name string) OneDriveOption { return func(o *oneDriveOptions) { @@ -59,6 +69,13 @@ func WithOneDriveTokenPath(path string) OneDriveOption { } } +// WithOneDriveTokenRef sets the secret reference where the OAuth token is cached. +func WithOneDriveTokenRef(ref string) OneDriveOption { + return func(o *oneDriveOptions) { + o.tokenRef = ref + } +} + // WithOneDriveExcludePatterns sets the patterns used to exclude files and folders. func WithOneDriveExcludePatterns(patterns []string) OneDriveOption { return func(o *oneDriveOptions) { @@ -94,17 +111,47 @@ func NewOneDriveSource(ctx context.Context, opts ...OneDriveOption) (*OneDriveSo Scopes: []string{"Files.Read", "Files.Read.All", "User.Read", "offline_access"}, Endpoint: microsoft.AzureADEndpoint("common"), } + if cfg.tokenRef != "" && cfg.resolver == nil { + return nil, fmt.Errorf("onedrive auth: token ref %q requires a resolver", cfg.tokenRef) + } + + var token *oauth2.Token + var err error + if cfg.tokenRef != "" { + token, err = loadTokenRef(ctx, cfg.resolver, cfg.tokenRef) + } else { + token, err = loadToken(cfg.tokenPath) + } - token, err := loadToken(cfg.tokenPath) if err != nil { token, err = exchangeWithLocalServer(conf, oauth2.AccessTypeOffline) if err != nil { return nil, fmt.Errorf("onedrive auth: %w", err) } - _ = saveTokenJSON(cfg.tokenPath, token) + if cfg.tokenRef != "" { + _ = saveTokenRefJSON(ctx, cfg.resolver, cfg.tokenRef, token) + } else { + _ = saveTokenJSON(cfg.tokenPath, token) + } } - client := conf.Client(ctx, token) + // Use a persistent token source so that refreshes are saved. + ts := oauth2.ReuseTokenSource(token, conf.TokenSource(ctx, token)) + pts := &persistentTokenSource{ + ts: ts, + lastTok: token, + save: func(t *oauth2.Token) error { + if cfg.tokenRef != "" { + return saveTokenRefJSON(ctx, cfg.resolver, cfg.tokenRef, t) + } + if cfg.tokenPath != "" { + return saveTokenJSON(cfg.tokenPath, t) + } + return nil + }, + } + + client := oauth2.NewClient(ctx, pts) rootPath := normalizeOneDriveRootPath(cfg.rootPath) src := &OneDriveSource{ @@ -291,16 +338,32 @@ func loadToken(file string) (*oauth2.Token, error) { return &tok, err } +func loadTokenRef(ctx context.Context, r *secretref.Resolver, ref string) (*oauth2.Token, error) { + data, err := r.LoadBlob(ctx, ref) + if err != nil { + return nil, err + } + var tok oauth2.Token + if err := json.Unmarshal(data, &tok); err != nil { + return nil, fmt.Errorf("decode token from ref: %w", err) + } + return &tok, nil +} + func saveTokenJSON(file string, token *oauth2.Token) error { - if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil { - return fmt.Errorf("create token directory: %w", err) + data, err := json.Marshal(token) + if err != nil { + return err } - f, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + return paths.SaveAtomic(file, data) +} + +func saveTokenRefJSON(ctx context.Context, r *secretref.Resolver, ref string, token *oauth2.Token) error { + data, err := json.Marshal(token) if err != nil { return err } - defer func() { _ = f.Close() }() - return json.NewEncoder(f).Encode(token) + return r.SaveBlob(ctx, ref, data) } // Graph API Models diff --git a/pkg/source/onedrive_auth_test.go b/pkg/source/onedrive_auth_test.go new file mode 100644 index 0000000..1b1ef86 --- /dev/null +++ b/pkg/source/onedrive_auth_test.go @@ -0,0 +1,93 @@ +package source + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/cloudstic/cli/internal/secretref" + "golang.org/x/oauth2" +) + +func TestOneDrive_TokenPersistence(t *testing.T) { + tmpDir := t.TempDir() + tokPath := filepath.Join(tmpDir, "onedrive-token.json") + tok := &oauth2.Token{AccessToken: "od-secret-token", RefreshToken: "od-refresh"} + + // 1. Save and Load from file + if err := saveTokenJSON(tokPath, tok); err != nil { + t.Fatalf("saveTokenJSON failed: %v", err) + } + got, err := loadToken(tokPath) + if err != nil { + t.Fatalf("loadToken failed: %v", err) + } + if got.AccessToken != tok.AccessToken { + t.Errorf("got %q, want %q", got.AccessToken, tok.AccessToken) + } + + // 2. Save and Load from ref + resolver := secretref.NewDefaultResolver() + refPath := filepath.Join(tmpDir, "od-ref-token.json") + ref := "file://" + refPath + ctx := context.Background() + + if err := saveTokenRefJSON(ctx, resolver, ref, tok); err != nil { + t.Fatalf("saveTokenRefJSON failed: %v", err) + } + gotRef, err := loadTokenRef(ctx, resolver, ref) + if err != nil { + t.Fatalf("loadTokenRef failed: %v", err) + } + if gotRef.AccessToken != tok.AccessToken { + t.Errorf("got %q, want %q", gotRef.AccessToken, tok.AccessToken) + } +} + +func TestOneDrive_Options(t *testing.T) { + resolver := secretref.NewDefaultResolver() + opts := []OneDriveOption{ + WithOneDriveClientID("client-id"), + WithOneDriveResolver(resolver), + WithOneDriveDriveName("Personal"), + WithOneDriveRootPath("/Documents"), + WithOneDriveTokenPath("/path/od.json"), + WithOneDriveTokenRef("keychain://od-tok"), + WithOneDriveExcludePatterns([]string{"Temp"}), + } + + var cfg oneDriveOptions + for _, opt := range opts { + opt(&cfg) + } + + if cfg.clientID != "client-id" { + t.Error("clientID not set") + } + if cfg.resolver != resolver { + t.Error("resolver not set") + } + if cfg.driveName != "Personal" { + t.Error("driveName not set") + } + if cfg.rootPath != "/Documents" { + t.Error("rootPath not set") + } + if cfg.tokenPath != "/path/od.json" { + t.Error("tokenPath not set") + } + if cfg.tokenRef != "keychain://od-tok" { + t.Error("tokenRef not set") + } +} + +func TestNewOneDriveSource_RequiresResolverForTokenRef(t *testing.T) { + _, err := NewOneDriveSource(context.Background(), WithOneDriveTokenRef("config-token://onedrive/test")) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "requires a resolver") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/rfcs/0016-secure-auth-material-storage.md b/rfcs/0016-secure-auth-material-storage.md index b33c6e2..f00fa46 100644 --- a/rfcs/0016-secure-auth-material-storage.md +++ b/rfcs/0016-secure-auth-material-storage.md @@ -1,6 +1,6 @@ # RFC 0016: Secure Auth Material Storage -- **Status:** Draft +- **Status:** Implemented - **Date:** 2026-03-17 - **Affects:** `cmd/cloudstic/{auth,backup,profile}`, `internal/engine/profiles`, `internal/paths`, `internal/secretref`, `pkg/source/{gdrive,onedrive}` @@ -21,7 +21,7 @@ allowing native secure stores and app-managed encrypted local storage. ## Context Cloudstic already has a secret reference abstraction from RFC 0011 for string -secrets such as passwords and API keys. +secrets such as passwords and API keys (implemented in `internal/secretref`). Current OAuth handling is different: @@ -41,9 +41,9 @@ RFC 0011 explicitly left this space open as a follow-up. ## Goals - Improve at-rest protection for OAuth tokens and similar auth blobs. -- Reuse the existing reference-oriented UX where it fits. +- Reuse and unify with the existing reference-oriented UX. - Preserve compatibility with current file-path-based configs. -- Support mutable credentials that need read/write/update operations. +- Support mutable credentials that need atomic read/write/update operations. - Keep headless and automation workflows working with explicit file-based storage. - Avoid leaking auth material in CLI output, logs, or profile display. @@ -52,34 +52,41 @@ RFC 0011 explicitly left this space open as a follow-up. - No breaking removal of existing `*_token_file` fields in this RFC. - No mandatory migration of all existing auth entries. -- No redesign of RFC 0011 string secret resolution semantics. - No provider-specific OAuth flow redesign. - No claim that retrievable local secrets are safe under a fully compromised user session. ## Proposal -### 1. Introduce a separate auth material storage abstraction +### 1. Unified Secret and Auth Material Abstraction -Keep `secretref.Resolve(...)` focused on retrievable string secrets. +Instead of creating a completely separate system, we will extend the existing +`internal/secretref` infrastructure to support binary blobs and mutable state. -Add a separate abstraction for mutable auth blobs: +A `Backend` in `internal/secretref` may optionally implement the following +interfaces: ```go -type AuthMaterialStore interface { - Load(ctx context.Context, ref string) ([]byte, error) - Save(ctx context.Context, ref string, data []byte) error - Delete(ctx context.Context, ref string) error +type BlobBackend interface { + LoadBlob(ctx context.Context, ref Ref) ([]byte, error) +} + +type WritableBlobBackend interface { + BlobBackend + // SaveBlob must be atomic (e.g., write-to-temp-then-rename for files) + SaveBlob(ctx context.Context, ref Ref, data []byte) error + DeleteBlob(ctx context.Context, ref Ref) error } ``` Rationale: -- OAuth tokens are read and rewritten over time. -- Some auth material is JSON and should be treated as an opaque blob. -- A blob-oriented interface avoids overloading the simpler secret resolver. +- OAuth tokens are structured JSON blobs. +- Unifying under `internal/secretref` allows reusing the same URI schemes + (`keychain://`, `file://`) for both static strings and mutable blobs. +- The `Resolver` will route calls to the appropriate interface. -### 2. Add reference fields for token storage +### 2. Add reference fields for token and credential storage Extend auth/profile schema with storage references parallel to current path fields. @@ -88,101 +95,63 @@ Initial fields: - `google_token_ref` - `onedrive_token_ref` +- `google_credentials_ref` (for service account JSON) -Potential follow-up fields if needed: - -- `google_credentials_ref` - -Resolution precedence for auth material becomes: +Resolution precedence for auth material: 1. explicit CLI file path flag -2. `*_token_ref` -3. existing `*_token_file` +2. `*_ref` +3. existing `*_file` (path-based) 4. derived default app-managed token location -This keeps existing CLI flags and file workflows working unchanged. - -### 3. Define auth material reference schemes - -Initial schemes: - -- `file://` -- `config-token:///` -- `keychain:///` - -Scheme intent: - -- `file://` preserves explicit file-based storage. -- `config-token://` gives Cloudstic a stable app-managed reference without - exposing raw paths in user config. -- `keychain://` enables native secure storage on supported platforms for token - blobs. - -Later platform-specific schemes may include: - -- `wincred://...` -- `secret-service://...` +### 3. Unified Reference Schemes -### 4. Separate blob refs from string secret refs conceptually +We will use the same scheme names as RFC 0011 to maintain a consistent UX: -The syntax can remain URI-shaped, but the behavior should differ clearly: +- `file://`: Preserves explicit file-based storage. +- `config-token:///`: App-managed reference relative to config. +- `keychain:///`: Native secure storage for blobs. -- string secret refs resolve to plaintext values -- auth material refs load and save opaque bytes +### 4. Encrypted Local Fallback and Key Derivation -This avoids confusing semantics like treating a mutable token JSON document as a -single resolved string. +Cloudstic will support an encrypted local fallback (`config-token://`) for +environments without native keychains. -Implementation may reuse parser/registration patterns from `internal/secretref`, -but the runtime contracts should remain separate. +**Key Derivation Strategy:** -### 5. Add an encrypted local fallback backend +1. If a native store is available, a "Master Key" is generated and stored + securely (e.g., in macOS Keychain). +2. If no native store exists, the key is derived from a combination of: + - Machine-specific ID (e.g., `/etc/machine-id` or OS-specific equivalent). + - User SID/UID. + - A local salt file with restricted permissions (`0600`). +3. Encryption uses AES-256-GCM (Authenticated Encryption). -Cloudstic should support a secure fallback even where native credential stores -are missing or impractical. +**File Permissions:** +All files created by `AuthMaterialStore` (encrypted or plaintext) MUST be +created with `0600` permissions, and their containing directories with `0700`. -Preferred fallback model: +### 5. Atomic Updates and Concurrency -- token blob stored in app config directory -- blob encrypted at rest by Cloudstic before writing -- encryption key stored in the native secret backend when available -- file permissions remain restrictive (`0700` directories, `0600` files) +To prevent corruption during OAuth token refreshes: -This gives a better default than plaintext token JSON files while keeping local -portability and deterministic behavior. +- `SaveBlob` implementations MUST be atomic. +- For `file://` and `config-token://`, this requires writing to a `.tmp` file + on the same filesystem and using `os.Rename`. +- Cloudstic should use a lightweight advisory lock (e.g., `flock` or a lockfile) + when updating a specific reference to prevent race conditions between + concurrent processes using the same profile. -If native secure storage is unavailable, the encrypted local backend may derive -or provision a local app-specific key using the best available platform option, -with a documented fallback to plain file storage only when necessary. +### 6. Update Source Implementations -### 6. Update source implementations to use bytes, not paths, internally - -Google Drive and OneDrive source setup should stop assuming that token state is -always a file on disk. +Google Drive and OneDrive source setup will be refactored to use the +`secretref.Resolver` for both reading and updating tokens. New flow: -- auth/profile resolution chooses a token storage reference or explicit file path -- source auth loader reads token bytes via the configured backend -- OAuth refresh writes updated token bytes back via the same backend - -This keeps provider-specific code focused on OAuth behavior instead of storage -mechanics. - -### 7. CLI and UX behavior - -`auth new` and related flows should evolve toward reference-first behavior: - -- for interactive use, prefer secure app-managed storage by default -- allow explicit `-google-token-file` / `-onedrive-token-file` overrides -- display refs in `auth show` / `profile show`, never token contents - -Possible UX additions: - -- `cloudstic auth migrate-token-storage` -- `cloudstic auth doctor` - -These are not required for the initial implementation. +- `source.New(...)` receives a `secretref.Resolver` and the `token_ref`. +- The source loads the initial token bytes. +- When the token is refreshed, the source calls `resolver.SaveBlob(ref, data)`. ## Example configuration @@ -192,7 +161,8 @@ version: 1 auth: google-work: provider: google - google_credentials: /Users/alice/.config/gcloud/application_default_credentials.json + # Storing service account JSON in Keychain! + google_credentials_ref: keychain://cloudstic/auth/google-creds google_token_ref: config-token://google/google-work onedrive-personal: @@ -201,97 +171,29 @@ auth: onedrive_token_ref: keychain://cloudstic/auth/onedrive-personal ``` -Backward-compatible legacy form remains valid: - -```yaml -auth: - google-work: - provider: google - google_token_file: /Users/alice/.config/cloudstic/tokens/google-work.json -``` - ## Security considerations -- CLI output and logs must never print token contents. -- Errors may include the failing field/ref, but not secret material. -- Blob backends should avoid long-lived plaintext caches. -- App-managed encrypted files should use authenticated encryption. -- Migration commands should delete replaced plaintext files only after verified - successful write to the new backend. -- Documentation must clearly describe that native stores improve default posture - but do not protect against a fully compromised local session. - -## Backward compatibility and migration - -- Existing `*_token_file` fields remain supported. -- Existing token files continue to work with no migration. -- New interactive flows should prefer `*_token_ref` where supported. -- An optional migration command can convert stored file paths into managed refs. - -Migration example: - -```yaml -# Before -google_token_file: /Users/alice/.config/cloudstic/tokens/google-work.json - -# After -google_token_ref: config-token://google/google-work -``` - -## Alternatives considered - -### 1. Reuse `secretref.Resolve(...)` unchanged - -Rejected as the primary design because it only models read-only string -resolution and does not fit mutable JSON token storage. - -### 2. Keep plaintext token files, but tighten path handling only - -Helpful, but insufficient. Better defaults and path hygiene do not address the -core problem of long-lived refresh tokens sitting unencrypted on disk. - -### 3. Store all auth blobs directly in native backends only +- **No Leaks:** CLI output and logs must never print token/blob contents. +- **Atomic Renames:** Prevents partial writes during crashes. +- **Secure Fallback:** Encrypted at rest even when not using native stores. +- **Auditability:** Errors should indicate which reference failed without + revealing the secret. -Too restrictive for headless, CI, and cross-platform environments where native -stores may be unavailable or unsuitable. +## Backward compatibility -### 4. Encrypt local token files with a native-store-held key - -This remains a strong option and is included in this RFC as the preferred -fallback for app-managed storage. +- `*_token_file` and `google_credentials` (path-based) remain fully supported. +- `secretref.Resolve()` continues to work for string-only backends (like `env://`). ## Testing strategy -- Unit tests for auth material ref parsing and backend routing. -- Unit tests for profile/auth precedence between `*_token_ref` and - `*_token_file`. -- Unit tests for round-trip load/save of token blobs. -- Provider tests verifying refreshed OAuth tokens persist through the selected - backend. -- Platform-specific tests should remain opt-in where native stores are not - always available. - -## Rollout plan - -1. Add schema fields and storage abstraction for auth material refs. -2. Implement `file://` and `config-token://` backends. -3. Refactor Google Drive and OneDrive token persistence to use the abstraction. -4. Add `keychain://` blob support on macOS. -5. Update interactive auth/profile flows to prefer managed secure storage. -6. Add optional migration tooling and docs. - -## Relationship to other RFCs - -- RFC 0011 introduced secret references for string secrets and explicitly left - OAuth token storage as a future follow-up. -- This RFC complements RFC 0011 rather than replacing it. +- Unit tests for atomic save behavior. +- Integration tests for `keychain://` blob storage on supported OSs. +- Concurrency tests ensuring two processes don't corrupt the same token file. +- Permission checks verifying `0600` on created files. ## Open questions -- Should `google_credentials` service-account JSON remain path-based, gain its - own `*_ref`, or continue to rely on external provider defaults? -- Should `config-token://` be implemented as encrypted files from the start, or - first as an abstraction over current plaintext files with encryption added in - the next step? -- Should native token blob storage reuse the same `keychain://` namespace as - string secrets, or a distinct `keychain-token://` scheme? +- **Locking Scope:** Should locking be handled by the `Resolver` or the + individual `Backend`? (Recommended: `Resolver` to ensure consistency). +- **Migration Tooling:** Should we provide a `cloudstic auth migrate` command + to move tokens from files to keychain automatically?