diff --git a/cmd/cloudstic/cmd_store_test.go b/cmd/cloudstic/cmd_store_test.go index e5e5792..ce24742 100644 --- a/cmd/cloudstic/cmd_store_test.go +++ b/cmd/cloudstic/cmd_store_test.go @@ -5,7 +5,6 @@ import ( "errors" "os" "path/filepath" - "runtime" "strings" "testing" @@ -1181,7 +1180,7 @@ func TestConfigureStoreEncryptionSelection_KMSError(t *testing.T) { func TestPromptSecretReference_EnvInteractive(t *testing.T) { t.Setenv("MY_ENV", "set-for-test") - if runtime.GOOS == "darwin" { + if len(profileSecretResolver.WritableBackends()) > 0 { setInteractiveStdinLines(t, "1", "MY_ENV") } else { setInteractiveStdinLines(t, "MY_ENV") @@ -1210,7 +1209,7 @@ func TestPromptEncryptionConfig_PasswordViaEnvRef(t *testing.T) { }, } - if runtime.GOOS == "darwin" { + if len(profileSecretResolver.WritableBackends()) > 0 { setInteractiveStdinLines(t, "1", "1", "MY_BACKUP_PASSWORD") } else { setInteractiveStdinLines(t, "1", "MY_BACKUP_PASSWORD") diff --git a/internal/secretref/secret_service_backend.go b/internal/secretref/secret_service_backend.go index b673ba5..654096d 100644 --- a/internal/secretref/secret_service_backend.go +++ b/internal/secretref/secret_service_backend.go @@ -13,23 +13,41 @@ var ( ) type secretServiceLookupFunc func(ctx context.Context, collection, item string) (string, error) +type secretServiceExistsFunc func(ctx context.Context, collection, item string) (bool, error) +type secretServiceStoreFunc func(ctx context.Context, collection, item, value string) error // SecretServiceBackend resolves secret-service://collection/item references. type SecretServiceBackend struct { lookup secretServiceLookupFunc + exists secretServiceExistsFunc + store secretServiceStoreFunc } // NewSecretServiceBackend creates a Secret Service backend for the current // platform. func NewSecretServiceBackend() *SecretServiceBackend { - return &SecretServiceBackend{lookup: defaultSecretServiceLookup} + return &SecretServiceBackend{ + lookup: defaultSecretServiceLookup, + exists: defaultSecretServiceExists, + store: defaultSecretServiceStore, + } } -func newSecretServiceBackendWithLookup(lookup secretServiceLookupFunc) *SecretServiceBackend { +func newSecretServiceBackendWithFns(lookup secretServiceLookupFunc, exists secretServiceExistsFunc, store secretServiceStoreFunc) *SecretServiceBackend { if lookup == nil { lookup = defaultSecretServiceLookup } - return &SecretServiceBackend{lookup: lookup} + if exists == nil { + exists = defaultSecretServiceExists + } + if store == nil { + store = defaultSecretServiceStore + } + return &SecretServiceBackend{lookup: lookup, exists: exists, store: store} +} + +func newSecretServiceBackendWithLookup(lookup secretServiceLookupFunc) *SecretServiceBackend { + return newSecretServiceBackendWithFns(lookup, nil, nil) } func parseSecretServicePath(path string) (collection string, item string, err error) { @@ -65,3 +83,38 @@ func (b *SecretServiceBackend) Resolve(ctx context.Context, ref Ref) (string, er return value, nil } + +func (b *SecretServiceBackend) Scheme() string { return "secret-service" } + +func (b *SecretServiceBackend) DisplayName() string { return "Secret Service" } + +func (b *SecretServiceBackend) WriteSupported() bool { return defaultSecretServiceWriteSupported() } + +func (b *SecretServiceBackend) DefaultRef(storeName, account string) string { + return "secret-service://cloudstic/" + storeName + "/" + account +} + +func (b *SecretServiceBackend) Exists(ctx context.Context, ref Ref) (bool, error) { + collection, item, err := parseSecretServicePath(ref.Path) + if err != nil { + return false, errorf(KindInvalidRef, ref.Raw, err.Error(), nil) + } + + exists, err := b.exists(ctx, collection, item) + if err != nil { + return false, errorf(KindBackendUnavailable, ref.Raw, err.Error(), err) + } + return exists, nil +} + +func (b *SecretServiceBackend) Store(ctx context.Context, ref Ref, value string) error { + collection, item, err := parseSecretServicePath(ref.Path) + if err != nil { + return errorf(KindInvalidRef, ref.Raw, err.Error(), nil) + } + + if err := b.store(ctx, collection, item, value); err != nil { + return errorf(KindBackendUnavailable, ref.Raw, err.Error(), err) + } + return nil +} diff --git a/internal/secretref/secret_service_backend_linux.go b/internal/secretref/secret_service_backend_linux.go index edbb30e..30c30dc 100644 --- a/internal/secretref/secret_service_backend_linux.go +++ b/internal/secretref/secret_service_backend_linux.go @@ -34,6 +34,8 @@ var secretServiceSessionBus = func() (secretServiceDBusConn, error) { return dbus.SessionBus() } +func defaultSecretServiceWriteSupported() bool { return true } + func defaultSecretServiceLookup(_ context.Context, collection, item string) (string, error) { conn, err := secretServiceSessionBus() if err != nil { @@ -64,6 +66,59 @@ func defaultSecretServiceLookup(_ context.Context, collection, item string) (str return string(secret.Value), nil } +func defaultSecretServiceExists(ctx context.Context, collection, item string) (bool, error) { + _, err := defaultSecretServiceLookup(ctx, collection, item) + if err != nil { + switch err { + case errSecretServiceNotFound: + return false, nil + default: + return false, err + } + } + return true, nil +} + +func defaultSecretServiceStore(_ context.Context, collection, item, value string) error { + conn, err := secretServiceSessionBus() + if err != nil { + return fmt.Errorf("%w: cannot connect to the session bus; ensure a desktop keyring/DBus session is available or use env://... as a fallback", errSecretServiceUnavailable) + } + defer func() { _ = conn.Close() }() + + service := conn.Object(secretServiceName, secretServicePath) + collectionPath, err := lookupSecretServiceCollection(conn, service, collection) + if err != nil { + return err + } + + var ignored dbus.Variant + var session dbus.ObjectPath + if err := service.Call(secretServiceInterface+".OpenSession", 0, "plain", dbus.MakeVariant("")).Store(&ignored, &session); err != nil { + return mapSecretServiceCallError(err, "open Secret Service session") + } + + properties := map[string]dbus.Variant{ + itemInterface + ".Label": dbus.MakeVariant(item), + itemInterface + ".Attributes": dbus.MakeVariant(map[string]string{"cloudstic_ref": collection + "/" + item}), + } + secret := secretServiceSecret{ + Session: session, + Parameters: nil, + Value: []byte(value), + ContentType: "text/plain; charset=utf-8", + } + var itemPath dbus.ObjectPath + var prompt dbus.ObjectPath + if err := conn.Object(secretServiceName, collectionPath).Call(collectionInterface+".CreateItem", 0, properties, secret, true).Store(&itemPath, &prompt); err != nil { + return mapSecretServiceCallError(err, "write secret to Secret Service") + } + if prompt != "" && prompt != "/" { + return fmt.Errorf("%w: Secret Service write requires user interaction in this session; use env://... as a fallback", errSecretServiceUnavailable) + } + return nil +} + func lookupSecretServiceCollection(conn secretServiceDBusConn, service dbus.BusObject, want string) (dbus.ObjectPath, error) { if aliasPath, err := readSecretServiceAlias(service, want); err == nil && aliasPath != "" && aliasPath != "/" { return aliasPath, nil diff --git a/internal/secretref/secret_service_backend_linux_test.go b/internal/secretref/secret_service_backend_linux_test.go index 345e671..7070cc4 100644 --- a/internal/secretref/secret_service_backend_linux_test.go +++ b/internal/secretref/secret_service_backend_linux_test.go @@ -30,3 +30,17 @@ func TestDefaultSecretServiceLookupMissingSessionBus(t *testing.T) { t.Fatalf("expected errSecretServiceUnavailable, got %v", err) } } + +func TestDefaultSecretServiceExistsNotFound(t *testing.T) { + orig := secretServiceSessionBus + defer func() { secretServiceSessionBus = orig }() + + secretServiceSessionBus = func() (secretServiceDBusConn, error) { + return nil, errors.New("dbus session unavailable") + } + + _, err := defaultSecretServiceExists(context.Background(), "cloudstic", "prod/password") + if !errors.Is(err, errSecretServiceUnavailable) { + t.Fatalf("expected errSecretServiceUnavailable, got %v", err) + } +} diff --git a/internal/secretref/secret_service_backend_stub.go b/internal/secretref/secret_service_backend_stub.go index 9f283db..ed4322a 100644 --- a/internal/secretref/secret_service_backend_stub.go +++ b/internal/secretref/secret_service_backend_stub.go @@ -7,6 +7,16 @@ import ( "fmt" ) +func defaultSecretServiceWriteSupported() bool { return false } + func defaultSecretServiceLookup(_ context.Context, _, _ string) (string, error) { return "", fmt.Errorf("%w: secret service backend is only available on Linux; use env://... as a fallback in headless environments", errSecretServiceUnavailable) } + +func defaultSecretServiceExists(_ context.Context, _, _ string) (bool, error) { + return false, fmt.Errorf("%w: secret service backend is only available on Linux; use env://... as a fallback in headless environments", errSecretServiceUnavailable) +} + +func defaultSecretServiceStore(_ context.Context, _, _, _ string) error { + return fmt.Errorf("%w: secret service backend is only available on Linux; use env://... as a fallback in headless environments", errSecretServiceUnavailable) +} diff --git a/internal/secretref/secret_service_backend_test.go b/internal/secretref/secret_service_backend_test.go index 45f3416..6f40768 100644 --- a/internal/secretref/secret_service_backend_test.go +++ b/internal/secretref/secret_service_backend_test.go @@ -91,3 +91,46 @@ func TestSecretServiceBackendResolveErrors(t *testing.T) { }) } } + +func TestSecretServiceBackend_DefaultRef(t *testing.T) { + b := NewSecretServiceBackend() + if got := b.DefaultRef("prod", "password"); got != "secret-service://cloudstic/prod/password" { + t.Fatalf("DefaultRef() = %q", got) + } +} + +func TestSecretServiceBackend_Exists(t *testing.T) { + b := newSecretServiceBackendWithFns( + func(context.Context, string, string) (string, error) { return "", nil }, + func(_ context.Context, collection, item string) (bool, error) { + if collection != "cloudstic" || item != "prod/password" { + t.Fatalf("unexpected args %q/%q", collection, item) + } + return true, nil + }, + nil, + ) + exists, err := b.Exists(context.Background(), Ref{Raw: "secret-service://cloudstic/prod/password", Scheme: "secret-service", Path: "cloudstic/prod/password"}) + if err != nil { + t.Fatalf("Exists: %v", err) + } + if !exists { + t.Fatal("expected exists=true") + } +} + +func TestSecretServiceBackend_Store(t *testing.T) { + b := newSecretServiceBackendWithFns( + func(context.Context, string, string) (string, error) { return "", nil }, + nil, + func(_ context.Context, collection, item, value string) error { + if collection != "cloudstic" || item != "prod/password" || value != "secret" { + t.Fatalf("unexpected args %q/%q/%q", collection, item, value) + } + return nil + }, + ) + if err := b.Store(context.Background(), Ref{Raw: "secret-service://cloudstic/prod/password", Scheme: "secret-service", Path: "cloudstic/prod/password"}, "secret"); err != nil { + t.Fatalf("Store: %v", err) + } +} diff --git a/internal/secretref/wincred_backend.go b/internal/secretref/wincred_backend.go index 6084bc5..a3276c8 100644 --- a/internal/secretref/wincred_backend.go +++ b/internal/secretref/wincred_backend.go @@ -13,23 +13,41 @@ var ( ) type wincredLookupFunc func(ctx context.Context, target string) (string, error) +type wincredExistsFunc func(ctx context.Context, target string) (bool, error) +type wincredStoreFunc func(ctx context.Context, target, value string) error // WincredBackend resolves wincred://target references. type WincredBackend struct { lookup wincredLookupFunc + exists wincredExistsFunc + store wincredStoreFunc } // NewWincredBackend creates a Windows Credential Manager backend for the // current platform. func NewWincredBackend() *WincredBackend { - return &WincredBackend{lookup: defaultWincredLookup} + return &WincredBackend{ + lookup: defaultWincredLookup, + exists: defaultWincredExists, + store: defaultWincredStore, + } } -func newWincredBackendWithLookup(lookup wincredLookupFunc) *WincredBackend { +func newWincredBackendWithFns(lookup wincredLookupFunc, exists wincredExistsFunc, store wincredStoreFunc) *WincredBackend { if lookup == nil { lookup = defaultWincredLookup } - return &WincredBackend{lookup: lookup} + if exists == nil { + exists = defaultWincredExists + } + if store == nil { + store = defaultWincredStore + } + return &WincredBackend{lookup: lookup, exists: exists, store: store} +} + +func newWincredBackendWithLookup(lookup wincredLookupFunc) *WincredBackend { + return newWincredBackendWithFns(lookup, nil, nil) } func parseWincredTarget(path string) (string, error) { @@ -61,3 +79,38 @@ func (b *WincredBackend) Resolve(ctx context.Context, ref Ref) (string, error) { return value, nil } + +func (b *WincredBackend) Scheme() string { return "wincred" } + +func (b *WincredBackend) DisplayName() string { return "Windows Credential Manager" } + +func (b *WincredBackend) WriteSupported() bool { return defaultWincredWriteSupported() } + +func (b *WincredBackend) DefaultRef(storeName, account string) string { + return "wincred://cloudstic/store/" + storeName + "/" + account +} + +func (b *WincredBackend) Exists(ctx context.Context, ref Ref) (bool, error) { + target, err := parseWincredTarget(ref.Path) + if err != nil { + return false, errorf(KindInvalidRef, ref.Raw, err.Error(), nil) + } + + exists, err := b.exists(ctx, target) + if err != nil { + return false, errorf(KindBackendUnavailable, ref.Raw, err.Error(), err) + } + return exists, nil +} + +func (b *WincredBackend) Store(ctx context.Context, ref Ref, value string) error { + target, err := parseWincredTarget(ref.Path) + if err != nil { + return errorf(KindInvalidRef, ref.Raw, err.Error(), nil) + } + + if err := b.store(ctx, target, value); err != nil { + return errorf(KindBackendUnavailable, ref.Raw, err.Error(), err) + } + return nil +} diff --git a/internal/secretref/wincred_backend_integration_windows_test.go b/internal/secretref/wincred_backend_integration_windows_test.go index 8535f0e..be518df 100644 --- a/internal/secretref/wincred_backend_integration_windows_test.go +++ b/internal/secretref/wincred_backend_integration_windows_test.go @@ -13,10 +13,7 @@ import ( "golang.org/x/sys/windows" ) -const credPersistSession = 1 - var ( - procCredWriteW = advapi32DLL.NewProc("CredWriteW") procCredDeleteW = advapi32DLL.NewProc("CredDeleteW") ) @@ -24,17 +21,18 @@ func TestWincredBackendIntegration(t *testing.T) { target := fmt.Sprintf("cloudstic-test-%d", time.Now().UnixNano()) secret := "cloudstic-test-secret" - if err := writeTestGenericCredential(target, secret); err != nil { - if errors.Is(err, windows.ERROR_NO_SUCH_LOGON_SESSION) { + b := NewWincredBackend() + if err := b.Store(context.Background(), Ref{Raw: "wincred://" + target, Scheme: "wincred", Path: target}, secret); err != nil { + var refErr *Error + if errors.As(err, &refErr) && refErr.Kind == KindBackendUnavailable { t.Skipf("Credential Manager unavailable in this logon session: %v", err) } - t.Fatalf("write test credential: %v", err) + t.Fatalf("Store: %v", err) } t.Cleanup(func() { _ = deleteTestGenericCredential(target) }) - b := NewWincredBackend() got, err := b.Resolve(context.Background(), Ref{ Raw: "wincred://" + target, Scheme: "wincred", @@ -52,28 +50,6 @@ func TestWincredBackendIntegration(t *testing.T) { } } -func writeTestGenericCredential(target, secret string) error { - targetPtr, err := windows.UTF16PtrFromString(target) - if err != nil { - return err - } - blob := []byte(secret) - cred := windowsCredential{ - Type: credTypeGeneric, - TargetName: targetPtr, - CredentialBlobSize: uint32(len(blob)), - Persist: credPersistSession, - } - if len(blob) > 0 { - cred.CredentialBlob = &blob[0] - } - r1, _, callErr := procCredWriteW.Call(uintptr(unsafe.Pointer(&cred)), 0) - if r1 == 0 { - return callErr - } - return nil -} - func deleteTestGenericCredential(target string) error { targetPtr, err := windows.UTF16PtrFromString(target) if err != nil { diff --git a/internal/secretref/wincred_backend_stub.go b/internal/secretref/wincred_backend_stub.go index 0bbeeac..2c6810b 100644 --- a/internal/secretref/wincred_backend_stub.go +++ b/internal/secretref/wincred_backend_stub.go @@ -7,6 +7,16 @@ import ( "fmt" ) +func defaultWincredWriteSupported() bool { return false } + func defaultWincredLookup(_ context.Context, _ string) (string, error) { return "", fmt.Errorf("%w: windows credential backend is only available on Windows", errWincredUnavailable) } + +func defaultWincredExists(_ context.Context, _ string) (bool, error) { + return false, fmt.Errorf("%w: windows credential backend is only available on Windows", errWincredUnavailable) +} + +func defaultWincredStore(_ context.Context, _, _ string) error { + return fmt.Errorf("%w: windows credential backend is only available on Windows", errWincredUnavailable) +} diff --git a/internal/secretref/wincred_backend_test.go b/internal/secretref/wincred_backend_test.go index ec13d92..944f639 100644 --- a/internal/secretref/wincred_backend_test.go +++ b/internal/secretref/wincred_backend_test.go @@ -105,3 +105,46 @@ func TestWincredBackendResolveErrors(t *testing.T) { }) } } + +func TestWincredBackend_DefaultRef(t *testing.T) { + b := NewWincredBackend() + if got := b.DefaultRef("prod", "password"); got != "wincred://cloudstic/store/prod/password" { + t.Fatalf("DefaultRef() = %q", got) + } +} + +func TestWincredBackend_Exists(t *testing.T) { + b := newWincredBackendWithFns( + func(context.Context, string) (string, error) { return "", nil }, + func(_ context.Context, target string) (bool, error) { + if target != "cloudstic/store/prod/password" { + t.Fatalf("unexpected target %q", target) + } + return true, nil + }, + nil, + ) + exists, err := b.Exists(context.Background(), Ref{Raw: "wincred://cloudstic/store/prod/password", Scheme: "wincred", Path: "cloudstic/store/prod/password"}) + if err != nil { + t.Fatalf("Exists: %v", err) + } + if !exists { + t.Fatal("expected exists=true") + } +} + +func TestWincredBackend_Store(t *testing.T) { + b := newWincredBackendWithFns( + func(context.Context, string) (string, error) { return "", nil }, + nil, + func(_ context.Context, target, value string) error { + if target != "cloudstic/store/prod/password" || value != "secret" { + t.Fatalf("unexpected args %q/%q", target, value) + } + return nil + }, + ) + if err := b.Store(context.Background(), Ref{Raw: "wincred://cloudstic/store/prod/password", Scheme: "wincred", Path: "cloudstic/store/prod/password"}, "secret"); err != nil { + t.Fatalf("Store: %v", err) + } +} diff --git a/internal/secretref/wincred_backend_windows.go b/internal/secretref/wincred_backend_windows.go index 3c4a272..791d33d 100644 --- a/internal/secretref/wincred_backend_windows.go +++ b/internal/secretref/wincred_backend_windows.go @@ -11,6 +11,7 @@ import ( ) const credTypeGeneric = 1 +const credPersistLocalMachine = 2 type windowsCredential struct { Flags uint32 @@ -28,12 +29,16 @@ type windowsCredential struct { } var ( - advapi32DLL = windows.NewLazySystemDLL("advapi32.dll") - procCredReadW = advapi32DLL.NewProc("CredReadW") - procCredFree = advapi32DLL.NewProc("CredFree") - wincredReadGenericCredential = readGenericCredential + advapi32DLL = windows.NewLazySystemDLL("advapi32.dll") + procCredReadW = advapi32DLL.NewProc("CredReadW") + procCredWriteW = advapi32DLL.NewProc("CredWriteW") + procCredFree = advapi32DLL.NewProc("CredFree") + wincredReadGenericCredential = readGenericCredential + wincredWriteGenericCredential = writeGenericCredential ) +func defaultWincredWriteSupported() bool { return true } + func defaultWincredLookup(_ context.Context, target string) (string, error) { value, err := wincredReadGenericCredential(target) if err != nil { @@ -49,6 +54,31 @@ func defaultWincredLookup(_ context.Context, target string) (string, error) { return value, nil } +func defaultWincredExists(ctx context.Context, target string) (bool, error) { + _, err := defaultWincredLookup(ctx, target) + if err != nil { + switch err { + case errWincredNotFound: + return false, nil + default: + return false, err + } + } + return true, nil +} + +func defaultWincredStore(_ context.Context, target, value string) error { + if err := wincredWriteGenericCredential(target, value); err != nil { + switch err { + case windows.ERROR_NO_SUCH_LOGON_SESSION: + return fmt.Errorf("%w: Credential Manager unavailable in this logon session; this is common in service or scheduled-task contexts without a loaded user profile", errWincredUnavailable) + default: + return fmt.Errorf("windows credential write failed: %w", err) + } + } + return nil +} + func readGenericCredential(target string) (string, error) { targetPtr, err := windows.UTF16PtrFromString(target) if err != nil { @@ -80,3 +110,28 @@ func readGenericCredential(target string) (string, error) { blob := unsafe.Slice(cred.CredentialBlob, cred.CredentialBlobSize) return string(blob), nil } + +func writeGenericCredential(target, value string) error { + targetPtr, err := windows.UTF16PtrFromString(target) + if err != nil { + return fmt.Errorf("invalid windows credential target: %w", err) + } + blob := []byte(value) + cred := windowsCredential{ + Type: credTypeGeneric, + TargetName: targetPtr, + CredentialBlobSize: uint32(len(blob)), + Persist: credPersistLocalMachine, + } + if len(blob) > 0 { + cred.CredentialBlob = &blob[0] + } + r1, _, callErr := procCredWriteW.Call(uintptr(unsafe.Pointer(&cred)), 0) + if r1 == 0 { + if callErr != nil && callErr != windows.ERROR_SUCCESS { + return callErr + } + return windows.ERROR_GEN_FAILURE + } + return nil +} diff --git a/internal/secretref/wincred_backend_windows_test.go b/internal/secretref/wincred_backend_windows_test.go index d1ef2e3..0b36faa 100644 --- a/internal/secretref/wincred_backend_windows_test.go +++ b/internal/secretref/wincred_backend_windows_test.go @@ -37,3 +37,34 @@ func TestDefaultWincredLookupMapsNoSuchLogonSession(t *testing.T) { t.Fatalf("expected errWincredUnavailable, got %v", err) } } + +func TestDefaultWincredExistsMapsNotFound(t *testing.T) { + orig := wincredReadGenericCredential + defer func() { wincredReadGenericCredential = orig }() + + wincredReadGenericCredential = func(string) (string, error) { + return "", windows.ERROR_NOT_FOUND + } + + exists, err := defaultWincredExists(context.Background(), "target") + if err != nil { + t.Fatalf("defaultWincredExists: %v", err) + } + if exists { + t.Fatal("expected exists=false") + } +} + +func TestDefaultWincredStoreMapsNoSuchLogonSession(t *testing.T) { + orig := wincredWriteGenericCredential + defer func() { wincredWriteGenericCredential = orig }() + + wincredWriteGenericCredential = func(string, string) error { + return windows.ERROR_NO_SUCH_LOGON_SESSION + } + + err := defaultWincredStore(context.Background(), "target", "secret") + if !errors.Is(err, errWincredUnavailable) { + t.Fatalf("expected errWincredUnavailable, got %v", err) + } +}