From 2aea0a01df947aeb8b1d17e9f61ab4b9d5c4f4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Tue, 17 Mar 2026 20:16:27 +0100 Subject: [PATCH] feat: add Windows credential secret backend --- .github/workflows/ci.yml | 22 ++++ .github/workflows/release.yml | 29 ++++- internal/secretref/secretref.go | 1 + internal/secretref/secretref_test.go | 1 + internal/secretref/wincred_backend.go | 63 +++++++++++ ...incred_backend_integration_windows_test.go | 90 +++++++++++++++ internal/secretref/wincred_backend_stub.go | 12 ++ internal/secretref/wincred_backend_test.go | 107 ++++++++++++++++++ internal/secretref/wincred_backend_windows.go | 82 ++++++++++++++ .../secretref/wincred_backend_windows_test.go | 39 +++++++ 10 files changed, 443 insertions(+), 3 deletions(-) create mode 100644 internal/secretref/wincred_backend.go create mode 100644 internal/secretref/wincred_backend_integration_windows_test.go create mode 100644 internal/secretref/wincred_backend_stub.go create mode 100644 internal/secretref/wincred_backend_test.go create mode 100644 internal/secretref/wincred_backend_windows.go create mode 100644 internal/secretref/wincred_backend_windows_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a9cad3..ea7f91b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,28 @@ jobs: slug: Cloudstic/cli files: unit.out,e2e_coverage.out token: ${{ secrets.CODECOV_TOKEN }} + + verify-platform-build-paths: + name: Verify platform build paths (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - name: macOS + os: macos-latest + run: go test ./internal/secretref ./cmd/cloudstic + - name: Windows + os: windows-latest + run: go test ./internal/secretref ./cmd/cloudstic + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Run platform-specific verification + run: ${{ matrix.run }} + build: name: Build runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3132935..b603964 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,9 +23,34 @@ jobs: go-version-file: go.mod - run: go test -race -count=1 ./... + verify-platform-build-paths: + name: Verify platform build paths (${{ matrix.name }}) + needs: test + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - name: macOS + os: macos-latest + run: go test ./internal/secretref ./cmd/cloudstic + - name: Windows + os: windows-latest + run: go test ./internal/secretref ./cmd/cloudstic + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Run platform-specific verification + run: ${{ matrix.run }} + release: name: Release - needs: test + needs: + - test + - verify-platform-build-paths runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -34,8 +59,6 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - - name: Verify macOS build paths - run: go test ./internal/secretref ./cmd/cloudstic - name: Install Syft uses: anchore/sbom-action/download-syft@v0 - uses: goreleaser/goreleaser-action@v6 diff --git a/internal/secretref/secretref.go b/internal/secretref/secretref.go index 0162ad5..c2fb499 100644 --- a/internal/secretref/secretref.go +++ b/internal/secretref/secretref.go @@ -104,6 +104,7 @@ func NewDefaultResolver() *Resolver { return NewResolver(map[string]Backend{ "env": NewEnvBackend(nil), "keychain": NewKeychainBackend(), + "wincred": NewWincredBackend(), }) } diff --git a/internal/secretref/secretref_test.go b/internal/secretref/secretref_test.go index 5a63044..8b58cb4 100644 --- a/internal/secretref/secretref_test.go +++ b/internal/secretref/secretref_test.go @@ -16,6 +16,7 @@ func TestParse(t *testing.T) { }{ {name: "env", in: "env://CLOUDSTIC_PASSWORD", scheme: "env", path: "CLOUDSTIC_PASSWORD"}, {name: "mixed case scheme", in: "KeyChain://service/account", scheme: "keychain", path: "service/account"}, + {name: "wincred", in: "WinCred://cloudstic/store/prod/password", scheme: "wincred", path: "cloudstic/store/prod/password"}, {name: "empty", in: "", wantErr: true}, {name: "missing separator", in: "env:CLOUDSTIC_PASSWORD", wantErr: true}, {name: "empty path", in: "env://", wantErr: true}, diff --git a/internal/secretref/wincred_backend.go b/internal/secretref/wincred_backend.go new file mode 100644 index 0000000..6084bc5 --- /dev/null +++ b/internal/secretref/wincred_backend.go @@ -0,0 +1,63 @@ +package secretref + +import ( + "context" + "errors" + "fmt" + "strings" +) + +var ( + errWincredNotFound = errors.New("windows credential not found") + errWincredUnavailable = errors.New("windows credential backend unavailable") +) + +type wincredLookupFunc func(ctx context.Context, target string) (string, error) + +// WincredBackend resolves wincred://target references. +type WincredBackend struct { + lookup wincredLookupFunc +} + +// NewWincredBackend creates a Windows Credential Manager backend for the +// current platform. +func NewWincredBackend() *WincredBackend { + return &WincredBackend{lookup: defaultWincredLookup} +} + +func newWincredBackendWithLookup(lookup wincredLookupFunc) *WincredBackend { + if lookup == nil { + lookup = defaultWincredLookup + } + return &WincredBackend{lookup: lookup} +} + +func parseWincredTarget(path string) (string, error) { + target := strings.TrimSpace(path) + target = strings.TrimPrefix(target, "/") + if target == "" { + return "", errors.New("expected wincred://") + } + return target, nil +} + +func (b *WincredBackend) Resolve(ctx context.Context, ref Ref) (string, error) { + target, err := parseWincredTarget(ref.Path) + if err != nil { + return "", errorf(KindInvalidRef, ref.Raw, err.Error(), nil) + } + + value, err := b.lookup(ctx, target) + if err != nil { + switch { + case errors.Is(err, errWincredNotFound): + return "", errorf(KindNotFound, ref.Raw, fmt.Sprintf("windows credential %q not found", target), err) + case errors.Is(err, errWincredUnavailable): + return "", errorf(KindBackendUnavailable, ref.Raw, err.Error(), err) + default: + return "", errorf(KindBackendUnavailable, ref.Raw, err.Error(), err) + } + } + + return value, nil +} diff --git a/internal/secretref/wincred_backend_integration_windows_test.go b/internal/secretref/wincred_backend_integration_windows_test.go new file mode 100644 index 0000000..8535f0e --- /dev/null +++ b/internal/secretref/wincred_backend_integration_windows_test.go @@ -0,0 +1,90 @@ +//go:build windows + +package secretref + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +const credPersistSession = 1 + +var ( + procCredWriteW = advapi32DLL.NewProc("CredWriteW") + procCredDeleteW = advapi32DLL.NewProc("CredDeleteW") +) + +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) { + t.Skipf("Credential Manager unavailable in this logon session: %v", err) + } + t.Fatalf("write test credential: %v", err) + } + t.Cleanup(func() { + _ = deleteTestGenericCredential(target) + }) + + b := NewWincredBackend() + got, err := b.Resolve(context.Background(), Ref{ + Raw: "wincred://" + target, + Scheme: "wincred", + Path: target, + }) + if err != nil { + var refErr *Error + if errors.As(err, &refErr) && refErr.Kind == KindBackendUnavailable { + t.Skipf("wincred backend unavailable: %v", err) + } + t.Fatalf("Resolve: %v", err) + } + if got != secret { + t.Fatalf("Resolve: got %q want %q", got, secret) + } +} + +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 { + return err + } + r1, _, callErr := procCredDeleteW.Call(uintptr(unsafe.Pointer(targetPtr)), uintptr(credTypeGeneric), 0) + if r1 == 0 { + if errors.Is(callErr, windows.ERROR_NOT_FOUND) { + return nil + } + return callErr + } + return nil +} diff --git a/internal/secretref/wincred_backend_stub.go b/internal/secretref/wincred_backend_stub.go new file mode 100644 index 0000000..0bbeeac --- /dev/null +++ b/internal/secretref/wincred_backend_stub.go @@ -0,0 +1,12 @@ +//go:build !windows + +package secretref + +import ( + "context" + "fmt" +) + +func defaultWincredLookup(_ context.Context, _ string) (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 new file mode 100644 index 0000000..ec13d92 --- /dev/null +++ b/internal/secretref/wincred_backend_test.go @@ -0,0 +1,107 @@ +package secretref + +import ( + "context" + "errors" + "testing" +) + +func TestParseWincredTarget(t *testing.T) { + tests := []struct { + name string + path string + want string + wantErr bool + }{ + {name: "basic", path: "cloudstic/store/prod/password", want: "cloudstic/store/prod/password"}, + {name: "leading slash", path: "/cloudstic/store/prod/password", want: "cloudstic/store/prod/password"}, + {name: "trim spaces", path: " cloudstic/store/prod/password ", want: "cloudstic/store/prod/password"}, + {name: "empty", path: "", wantErr: true}, + {name: "slash only", path: "/", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseWincredTarget(tc.path) + if tc.wantErr { + if err == nil { + t.Fatalf("parseWincredTarget(%q): expected error", tc.path) + } + return + } + if err != nil { + t.Fatalf("parseWincredTarget(%q): %v", tc.path, err) + } + if got != tc.want { + t.Fatalf("parseWincredTarget(%q): got %q want %q", tc.path, got, tc.want) + } + }) + } +} + +func TestWincredBackendResolve(t *testing.T) { + b := newWincredBackendWithLookup(func(_ context.Context, target string) (string, error) { + if target != "cloudstic/store/prod/password" { + t.Fatalf("unexpected lookup target %q", target) + } + return "s3cr3t", nil + }) + + got, err := b.Resolve(context.Background(), Ref{Raw: "wincred://cloudstic/store/prod/password", Scheme: "wincred", Path: "cloudstic/store/prod/password"}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != "s3cr3t" { + t.Fatalf("Resolve: got %q want s3cr3t", got) + } +} + +func TestWincredBackendResolveErrors(t *testing.T) { + tests := []struct { + name string + ref Ref + err error + kind ErrorKind + }{ + { + name: "invalid path", + ref: Ref{Raw: "wincred://", Scheme: "wincred", Path: ""}, + kind: KindInvalidRef, + }, + { + name: "not found", + ref: Ref{Raw: "wincred://cloudstic/store/prod/password", Scheme: "wincred", Path: "cloudstic/store/prod/password"}, + err: errWincredNotFound, + kind: KindNotFound, + }, + { + name: "unavailable", + ref: Ref{Raw: "wincred://cloudstic/store/prod/password", Scheme: "wincred", Path: "cloudstic/store/prod/password"}, + err: errWincredUnavailable, + kind: KindBackendUnavailable, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + b := newWincredBackendWithLookup(func(context.Context, string) (string, error) { + if tc.err == nil { + return "", nil + } + return "", tc.err + }) + + _, err := b.Resolve(context.Background(), tc.ref) + if err == nil { + t.Fatal("expected error") + } + var refErr *Error + if !errors.As(err, &refErr) { + t.Fatalf("expected *Error, got %T", err) + } + if refErr.Kind != tc.kind { + t.Fatalf("kind=%s want=%s", refErr.Kind, tc.kind) + } + }) + } +} diff --git a/internal/secretref/wincred_backend_windows.go b/internal/secretref/wincred_backend_windows.go new file mode 100644 index 0000000..3c4a272 --- /dev/null +++ b/internal/secretref/wincred_backend_windows.go @@ -0,0 +1,82 @@ +//go:build windows + +package secretref + +import ( + "context" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +const credTypeGeneric = 1 + +type windowsCredential struct { + Flags uint32 + Type uint32 + TargetName *uint16 + Comment *uint16 + LastWritten windows.Filetime + CredentialBlobSize uint32 + CredentialBlob *byte + Persist uint32 + AttributeCount uint32 + Attributes uintptr + TargetAlias *uint16 + UserName *uint16 +} + +var ( + advapi32DLL = windows.NewLazySystemDLL("advapi32.dll") + procCredReadW = advapi32DLL.NewProc("CredReadW") + procCredFree = advapi32DLL.NewProc("CredFree") + wincredReadGenericCredential = readGenericCredential +) + +func defaultWincredLookup(_ context.Context, target string) (string, error) { + value, err := wincredReadGenericCredential(target) + if err != nil { + switch err { + case windows.ERROR_NOT_FOUND: + return "", errWincredNotFound + 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 lookup failed: %w", err) + } + } + return value, nil +} + +func readGenericCredential(target string) (string, error) { + targetPtr, err := windows.UTF16PtrFromString(target) + if err != nil { + return "", fmt.Errorf("invalid windows credential target: %w", err) + } + + var cred *windowsCredential + r1, _, callErr := procCredReadW.Call( + uintptr(unsafe.Pointer(targetPtr)), + uintptr(credTypeGeneric), + 0, + uintptr(unsafe.Pointer(&cred)), + ) + if r1 == 0 { + if callErr != nil && callErr != windows.ERROR_SUCCESS { + return "", callErr + } + return "", windows.ERROR_GEN_FAILURE + } + defer procCredFree.Call(uintptr(unsafe.Pointer(cred))) + + if cred == nil { + return "", windows.ERROR_NOT_FOUND + } + if cred.CredentialBlob == nil || cred.CredentialBlobSize == 0 { + return "", nil + } + + blob := unsafe.Slice(cred.CredentialBlob, cred.CredentialBlobSize) + return string(blob), nil +} diff --git a/internal/secretref/wincred_backend_windows_test.go b/internal/secretref/wincred_backend_windows_test.go new file mode 100644 index 0000000..d1ef2e3 --- /dev/null +++ b/internal/secretref/wincred_backend_windows_test.go @@ -0,0 +1,39 @@ +//go:build windows + +package secretref + +import ( + "context" + "errors" + "testing" + + "golang.org/x/sys/windows" +) + +func TestDefaultWincredLookupMapsNotFound(t *testing.T) { + orig := wincredReadGenericCredential + defer func() { wincredReadGenericCredential = orig }() + + wincredReadGenericCredential = func(string) (string, error) { + return "", windows.ERROR_NOT_FOUND + } + + _, err := defaultWincredLookup(context.Background(), "target") + if !errors.Is(err, errWincredNotFound) { + t.Fatalf("expected errWincredNotFound, got %v", err) + } +} + +func TestDefaultWincredLookupMapsNoSuchLogonSession(t *testing.T) { + orig := wincredReadGenericCredential + defer func() { wincredReadGenericCredential = orig }() + + wincredReadGenericCredential = func(string) (string, error) { + return "", windows.ERROR_NO_SUCH_LOGON_SESSION + } + + _, err := defaultWincredLookup(context.Background(), "target") + if !errors.Is(err, errWincredUnavailable) { + t.Fatalf("expected errWincredUnavailable, got %v", err) + } +}